Merge remote-tracking branch 'origin/dev' into slack3

# Conflicts:
#	apps/x/apps/main/bundle.mjs
This commit is contained in:
Gagan 2026-06-17 12:51:58 -07:00
commit d1a606c3a9
59 changed files with 6542 additions and 386 deletions

View file

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

View file

@ -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/<agent>/<version>/`), 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/<target>/codex/codex` native binary
**plus a bundled `rg` (ripgrep) at `vendor/<target>/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-<platform>@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-<platform>``npm:@openai/codex@0.128.0-<platform>`:
`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/<pkg>/-/<file>-<version>.tgz`
- claude: `@anthropic-ai/claude-agent-sdk-<platform>@0.3.156` → extract the `claude` binary.
- codex: `@openai/codex@0.128.0-<platform>` → extract `vendor/<target>/codex/codex`
**and keep `vendor/<target>/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/<ver>/`. 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/<agent>/<version>/
3. If dir exists AND .meta/<agent>-<version>.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 <version>/ (tar gzip).
d. chmod +x the binary (and codex's rg) on unix.
e. Write .meta/<agent>-<version>.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 = <provisioned claude>`
- codex → `env.CODEX_PATH = <provisioned codex>` (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 <agent> 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/<target>/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.

View file

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

View file

@ -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 <entry>`
// 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/<agent>/<version>/ 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/');
},
}

View file

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

View file

@ -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<CodeSession> {
const repo = container.resolve<ICodeSessionsRepo>('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<void> {
if (codeSessionStatusWatcher) {
return;
}
const tracker = container.resolve<CodeSessionStatusTracker>('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<void> {
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<ICodeProjectsRepo>('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<ICodeProjectsRepo>('codeProjectsRepo');
await repo.remove(args.projectId);
return { success: true };
},
'codeProject:list': async () => {
const repo = container.resolve<ICodeProjectsRepo>('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>('codeSessionService');
const session = await service.create(args);
return { session };
},
'codeSession:list': async () => {
const repo = container.resolve<ICodeSessionsRepo>('codeSessionsRepo');
const tracker = container.resolve<CodeSessionStatusTracker>('codeSessionStatusTracker');
return { sessions: await repo.list(), statuses: tracker.getStatuses() };
},
'codeSession:update': async (_event, args) => {
const service = container.resolve<CodeSessionService>('codeSessionService');
return { session: await service.update(args.sessionId, args.patch) };
},
'codeSession:delete': async (_event, args) => {
const service = container.resolve<CodeSessionService>('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>('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>('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>('codeSessionService');
return service.mergeBack(args.sessionId);
},
'codeSession:cleanupWorktree': async (_event, args) => {
const service = container.resolve<CodeSessionService>('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<IGranolaConfigRepo>('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);

View file

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

View file

@ -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<string, TerminalEntry>();
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<string, string>,
});
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);
}

View file

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

View file

@ -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<RunListItem[]>([])
// Chat tab state
@ -1159,6 +1167,23 @@ function App() {
const [activeFileTabId, setActiveFileTabId] = useState<string | null>('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<ActiveCodeSession | null>(null)
// A file the code chat asked to review — consumed by the workspace pane.
const [codeDiffPath, setCodeDiffPath] = useState<string | null>(null)
const boundCodeSessionRef = useRef<string | null>(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<Record<string, { cwd: string; agent: 'claude' | 'codex' }>>({})
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(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<React.CSSProperties>(() => {
@ -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 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -5416,7 +5536,7 @@ function App() {
getTabId={(t) => 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 ? (
<ChatHeader
@ -5481,7 +5601,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!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 && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -5575,6 +5695,14 @@ function App() {
meetingSummarizing={meetingSummarizing}
/>
</div>
) : isCodeOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<CodeView
onSessionSelected={handleCodeSessionSelected}
openDiffPath={codeDiffPath}
onDiffOpened={() => setCodeDiffPath(null)}
/>
</div>
) : isLiveNotesOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<LiveNotesView
@ -6010,6 +6138,7 @@ function App() {
presetMessage={isActive ? presetMessage : undefined}
onPresetMessageConsumed={isActive ? () => setPresetMessage(undefined) : undefined}
runId={tabState.runId}
codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null}
initialDraft={chatDraftsRef.current.get(tab.id)}
onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
onSelectedModelChange={(m) => {
@ -6044,8 +6173,22 @@ function App() {
)}
</SidebarInset>
{/* Chat pane - shown when viewing files/graph */}
{isRightPaneContext && (
{/* Chat pane - shown when viewing files/graph. For a direct-mode
code session it swaps to the direct-drive chat; rowboat-mode
sessions use the regular assistant chat bound to their run. */}
{isRightPaneContext && isCodeOpen && activeCodeSession?.session.mode === 'direct' ? (
<ResizableRightPane
defaultWidth={DEFAULT_CHAT_PANE_WIDTH}
onActivate={() => setActiveShortcutPane('right')}
>
<CodeChat
key={activeCodeSession.session.id}
session={activeCodeSession.session}
status={activeCodeSession.status}
onOpenDiff={setCodeDiffPath}
/>
</ResizableRightPane>
) : isRightPaneContext && (
<ChatSidebar
placement={chatPanePlacement}
paneSize={chatPaneSize}
@ -6094,6 +6237,16 @@ function App() {
}}
workDirByTab={workDirByTab}
onWorkDirChangeForTab={setTabWorkDir}
codeSessionLocks={codeSessionLocks}
pinnedToCodeSession={
isCodeOpen
&& activeCodeSession?.session.mode === 'rowboat'
// Only while the pane is actually bound to the session — a
// palette-initiated fresh chat, for example, unbinds it.
&& chatTabs.find((t) => t.id === activeChatTabId)?.runId === activeCodeSession.session.id
? { title: activeCodeSession.session.title }
: null
}
pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses}

View file

@ -23,6 +23,7 @@ type Run = {
id: string
title?: string
createdAt: string
modifiedAt: string
agentId: string
}
@ -51,8 +52,8 @@ export function ChatHistoryView({
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
const at = new Date(a.modifiedAt).getTime()
const bt = new Date(b.modifiedAt).getTime()
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
})
}, [runs])
@ -92,7 +93,7 @@ export function ChatHistoryView({
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0">Created</div>
<div className="w-32 shrink-0 text-right">Last modified</div>
</div>
{sortedRuns.length === 0 ? (
@ -122,8 +123,8 @@ export function ChatHistoryView({
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.createdAt)}
<div className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.modifiedAt)}
</div>
</button>
</ContextMenuTrigger>

View file

@ -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 (
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
@ -807,7 +830,7 @@ function ChatInputInner({
<div ref={toolbarRef} className="flex items-center gap-2 px-4 pb-3">
<div ref={leftGroupRef} className="flex min-w-0 items-center gap-2 overflow-hidden">
<DropdownMenu>
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
@ -820,7 +843,7 @@ function ChatInputInner({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
{isCodeLocked ? 'Add files' : workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
@ -830,8 +853,23 @@ function ChatInputInner({
<span>Add files or photos</span>
</DropdownMenuItem>
{/* Working directory lives behind a submenu so the main menu stays to two
items. One hover/click away for power users; out of the way otherwise. */}
{/* A bound code session pins the directory — show it, no controls. */}
{isCodeLocked ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<div className="flex h-auto items-center gap-2 rounded-[9px] px-2.5 py-2 text-muted-foreground">
<FolderCheck className="size-4 shrink-0" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm">{currentWorkDirLabel}</span>
<span className="truncate text-xs">Pinned by the coding session</span>
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">{effectiveWorkDir}</TooltipContent>
</Tooltip>
) : (
/* Working directory lives behind a submenu so the main menu stays to two
items. One hover/click away for power users; out of the way otherwise. */
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
<FolderCog className="size-4" />
@ -845,18 +883,20 @@ function ChatInputInner({
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
{/* Current selection — shown for context only when one is set. */}
{workDir && (
<div
title={workDir}
className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
>
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
{currentWorkDirPath}
</span>
</span>
</div>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<div className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300">
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
{currentWorkDirPath}
</span>
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">{workDir}</TooltipContent>
</Tooltip>
)}
{/* Primary action: choose when unset, change when set. Always on top. */}
@ -877,16 +917,19 @@ function ChatInputInner({
const name = basename(entry.path) || entry.path
const when = formatRecentWorkDirTime(entry.lastUsedAt)
return (
<DropdownMenuItem
key={entry.path}
title={entry.path}
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
className="h-8 rounded-[9px] px-2.5"
>
<FolderClock className="size-4" />
<span className="min-w-0 flex-1 truncate">{name}</span>
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
</DropdownMenuItem>
<Tooltip key={entry.path} delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
className="h-8 rounded-[9px] px-2.5"
>
<FolderClock className="size-4" />
<span className="min-w-0 flex-1 truncate">{name}</span>
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">{entry.path}</TooltipContent>
</Tooltip>
)
})}
</>
@ -907,26 +950,31 @@ function ChatInputInner({
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
{workDir && collapseLevel < 8 && (
<Tooltip>
{effectiveWorkDir && collapseLevel < 8 && (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
{/* Level 4: collapse to a square icon */}
<div className={cn(
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors",
!isCodeLocked && "hover:bg-muted hover:text-foreground",
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
)}>
<button
type="button"
onClick={handleSetWorkDir}
className="flex min-w-0 items-center gap-1.5"
disabled={isCodeLocked}
className={cn("flex min-w-0 items-center gap-1.5", isCodeLocked && "cursor-default")}
>
<FolderCog className="h-3.5 w-3.5 shrink-0" />
{collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
{isCodeLocked
? <Lock className="h-3 w-3 shrink-0" />
: <FolderCog className="h-3.5 w-3.5 shrink-0" />}
{collapseLevel < 4 && <span className="truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>}
</button>
{collapseLevel < 4 && (
{collapseLevel < 4 && !isCodeLocked && (
<button
type="button"
onClick={handleClearWorkDir}
@ -939,7 +987,9 @@ function ChatInputInner({
</div>
</TooltipTrigger>
<TooltipContent side="top">
Work directory: {workDir}
{isCodeLocked
? `Pinned by the coding session: ${effectiveWorkDir}`
: `Work directory: ${effectiveWorkDir}`}
</TooltipContent>
</Tooltip>
)}
@ -965,7 +1015,7 @@ function ChatInputInner({
</button>
)}
{collapseLevel < 6 && (
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
@ -997,55 +1047,75 @@ function ChatInputInner({
</TooltipContent>
</Tooltip>
)}
{codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? (
{codeModeFeatureEnabled && collapseLevel < 5 && ((isCodeLocked || codeModeEnabled) ? (
collapseLevel >= 1 ? (
/* Level 1: collapse the pill to a single icon */
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70"
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
disabled={isCodeLocked}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<Terminal className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) click to disable</TooltipContent>
<TooltipContent side="top">
{isCodeLocked
? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}`
: `Code mode on (${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable`}
</TooltipContent>
</Tooltip>
) : (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
disabled={isCodeLocked}
className={cn(
"flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<Terminal className="h-3.5 w-3.5" />
{isCodeLocked ? <Lock className="h-3 w-3" /> : <Terminal className="h-3.5 w-3.5" />}
<span>Code</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on click to disable</TooltipContent>
<TooltipContent side="top">
{isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'}
</TooltipContent>
</Tooltip>
<span className="text-foreground/30">·</span>
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleCodingAgent}
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
disabled={isCodeLocked}
className={cn(
"flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
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`}
</TooltipContent>
</Tooltip>
</div>
)
) : (
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
@ -1062,7 +1132,7 @@ function ChatInputInner({
</div>
{collapseLevel >= 5 && (
<DropdownMenu>
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
@ -1077,10 +1147,10 @@ function ChatInputInner({
<TooltipContent side="top">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top" className="min-w-52">
{workDir && collapseLevel >= 8 && (
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
<FolderCog className="size-4" />
<span className="min-w-0 flex-1 truncate">{basename(workDir) || workDir}</span>
{effectiveWorkDir && collapseLevel >= 8 && (
<DropdownMenuItem disabled={isCodeLocked} onSelect={() => { void handleSetWorkDir() }}>
{isCodeLocked ? <Lock className="size-4" /> : <FolderCog className="size-4" />}
<span className="min-w-0 flex-1 truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>
</DropdownMenuItem>
)}
{searchAvailable && collapseLevel >= 7 && (
@ -1105,14 +1175,15 @@ function ChatInputInner({
{codeModeFeatureEnabled && collapseLevel >= 5 && (
<>
<DropdownMenuCheckboxItem
checked={codeModeEnabled}
checked={isCodeLocked || codeModeEnabled}
disabled={isCodeLocked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
>
Code mode
</DropdownMenuCheckboxItem>
{codeModeEnabled && (
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
{(isCodeLocked || codeModeEnabled) && (
<DropdownMenuItem disabled={isCodeLocked} onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
<Terminal className="size-4" />
<span className="min-w-0 flex-1">Coding agent</span>
<span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
@ -1125,12 +1196,16 @@ function ChatInputInner({
)}
<div className="flex-1" />
{lockedModel ? (
<span
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
>
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
</span>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} fixed for this chat
</TooltipContent>
</Tooltip>
) : configuredModels.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -1161,7 +1236,7 @@ function ChatInputInner({
) : null}
{onToggleTts && ttsAvailable && (
<div className="flex shrink-0 items-center">
<Tooltip>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
@ -1217,23 +1292,30 @@ function ChatInputInner({
</button>
)}
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<Button
size="icon"
onClick={onStop}
aria-label={isStopping ? 'Force stop generation' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isStopping ? 'Click again to force stop' : 'Stop generation'}
</TooltipContent>
</Tooltip>
) : (
<Button
size="icon"
@ -1308,6 +1390,8 @@ export interface ChatInputWithMentionsProps {
onSelectedModelChange?: (model: SelectedModel | null) => void
workDir?: string | null
onWorkDirChange?: (value: string | null) => void
/** Set when this chat is bound to a Code-section session — freezes workdir + agent. */
codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null
}
export function ChatInputWithMentions({
@ -1339,6 +1423,7 @@ export function ChatInputWithMentions({
onSelectedModelChange,
workDir,
onWorkDirChange,
codeSessionLock,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
@ -1368,6 +1453,7 @@ export function ChatInputWithMentions({
onSelectedModelChange={onSelectedModelChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}
codeSessionLock={codeSessionLock}
/>
</PromptInputProvider>
)

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@ -155,6 +155,13 @@ interface ChatSidebarProps {
onDraftChangeForTab?: (tabId: string, text: string) => void
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
workDirByTab?: Record<string, string | null>
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
/**
* Set while a Rowboat-mode code session owns this pane: the chat is pinned to
* the session, so the chat switcher / new-chat / history affordances hide.
*/
pinnedToCodeSession?: { title: string } | null
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
@ -216,6 +223,8 @@ export function ChatSidebar({
onDraftChangeForTab,
onSelectedModelChangeForTab,
workDirByTab = {},
codeSessionLocks = {},
pinnedToCodeSession = null,
onWorkDirChangeForTab,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
@ -555,17 +564,34 @@ export function ChatSidebar({
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
}}
>
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
{pinnedToCodeSession ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-1.5 px-3 py-2 text-sm font-medium">
<Pin className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{pinnedToCodeSession.title}</span>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
Coding session
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
This chat is pinned to the coding session leave the Code view to switch chats.
</TooltipContent>
</Tooltip>
) : (
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@ -646,9 +672,11 @@ export function ChatSidebar({
{!tabHasConversation ? (
<ChatEmptyState
wide={isMaximized}
recentRuns={recentRuns}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
// A pinned coding-session chat must not offer jumping
// to other conversations from the empty state either.
recentRuns={pinnedToCodeSession ? [] : recentRuns}
onSelectRun={pinnedToCodeSession ? undefined : onSelectRun}
onOpenChatHistory={pinnedToCodeSession ? undefined : onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
) : (
@ -779,6 +807,7 @@ export function ChatSidebar({
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
workDir={workDirByTab[tab.id] ?? null}
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null}
isRecording={isActive && isRecording}
recordingText={isActive ? recordingText : undefined}
recordingState={isActive ? recordingState : undefined}

View file

@ -0,0 +1,84 @@
import { EditorView, lineNumbers } from '@codemirror/view'
import { EditorState, type Extension } from '@codemirror/state'
import {
HighlightStyle,
LanguageDescription,
bracketMatching,
syntaxHighlighting,
defaultHighlightStyle,
} from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { tags } from '@lezer/highlight'
// Shared CodeMirror setup for the Code section's read-only viewers
// (file viewer + diff viewer). Theming keys off the app's resolved theme
// instead of pulling in a theme package.
const darkHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: '#c678dd' },
{ tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: '#e06c75' },
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' },
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{ tag: [tags.typeName, tags.className, tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: '#e5c07b' },
{ tag: [tags.operator, tags.operatorKeyword, tags.url, tags.escape, tags.regexp, tags.link, tags.special(tags.string)], color: '#56b6c2' },
{ tag: [tags.meta, tags.comment], color: '#7d8799', fontStyle: 'italic' },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
{ tag: tags.invalid, color: '#ffffff' },
])
export function cmBaseExtensions(isDark: boolean): Extension[] {
return [
lineNumbers(),
bracketMatching(),
syntaxHighlighting(isDark ? darkHighlight : defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping,
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.theme(
{
'&': {
backgroundColor: 'transparent',
fontSize: '12px',
height: '100%',
},
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
overflow: 'auto',
},
'.cm-gutters': {
backgroundColor: 'transparent',
border: 'none',
color: isDark ? '#6b7280' : '#9ca3af',
},
'&.cm-focused': { outline: 'none' },
// GitHub-style expander bar for folded unchanged regions (@codemirror/merge).
'.cm-collapsedLines': {
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.15)' : 'rgba(9, 105, 218, 0.08)',
backgroundImage: 'none',
color: isDark ? '#79c0ff' : '#0969da',
padding: '4px 12px',
fontSize: '11px',
cursor: 'pointer',
},
'.cm-collapsedLines:hover': {
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.25)' : 'rgba(9, 105, 218, 0.15)',
},
},
{ dark: isDark },
),
]
}
// Resolve a language extension from the filename (lazy-loaded; Vite splits
// each language into its own chunk).
export async function cmLanguageFor(filename: string): Promise<Extension | null> {
const desc = LanguageDescription.matchFilename(languages, filename)
if (!desc) return null
try {
return await desc.load()
} catch {
return null
}
}

View file

@ -0,0 +1,350 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-react'
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
import { MessageResponse } from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { toToolState, getToolDisplayName, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation'
import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding-run'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat'
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) {
const [open, setOpen] = useState(false)
const webSearch = getWebSearchCardData(item)
if (webSearch) {
return (
<WebSearchResult
query={webSearch.query}
results={webSearch.results}
status={item.status}
title={webSearch.title}
/>
)
}
if (item.name === 'code_agent_run') {
const agent = (item.result as { agent?: string } | undefined)?.agent
?? (item.input as { agent?: string } | undefined)?.agent
return (
<Tool open={open || item.status === 'running'} onOpenChange={setOpen}>
<ToolHeader title={AGENT_LABEL[agent ?? ''] ?? 'Coding agent'} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} onOpenDiff={onOpenDiff} />
</ToolContent>
</Tool>
)
}
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.status === 'running' || item.status === 'pending'
? <Loader2 className="size-3 animate-spin" />
: <span className="text-green-600"></span>}
<span className="truncate">{getToolDisplayName(item)}</span>
</div>
)
}
function ChatItem({ item, onOpenDiff }: { item: CodeChatItem; onOpenDiff: (path: string) => void }) {
if (isDirectTurn(item)) {
if (item.events.length === 0) return null
return (
<div className="rounded-[16px] border bg-muted/20">
<CodingRunTimeline events={item.events} onOpenDiff={onOpenDiff} />
</div>
)
}
if (isChatErrorMessage(item)) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{item.message.split('\n')[0]}
</div>
)
}
if (isChatToolCall(item)) {
return <RowboatToolCall item={item} onOpenDiff={onOpenDiff} />
}
if (item.role === 'user') {
return (
<div className="flex justify-end">
<div className="min-w-0 max-w-[85%] whitespace-pre-wrap break-words rounded-2xl bg-primary/10 px-4 py-2.5 text-sm">
{item.content}
</div>
</div>
)
}
return (
<div className="min-w-0 max-w-none break-words text-sm">
<MessageResponse>{item.content}</MessageResponse>
</div>
)
}
// Direct-drive chat for one coding session, rendered in the right-side pane in
// place of the assistant chat. Messages go straight to the ACP agent — when the
// session is in Rowboat mode this component isn't used (the real assistant
// chat pane is, bound to the session's run).
export function CodeChat({
session,
status,
onOpenDiff,
}: {
session: CodeSession
status: CodeSessionStatus
onOpenDiff: (path: string) => void
}) {
const {
items, liveText, isProcessing, pendingPermission, pendingToolPermissions, pendingAskHumans,
loading, send, stop, resolvePermission, respondToToolPermission, respondToAskHuman,
} = useCodeChat(session)
const [draft, setDraft] = useState('')
const [stopping, setStopping] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const busy = isProcessing || status === 'working' || status === 'needs-you'
// Attached file PATHS — like dragging a file into the Claude Code CLI, the
// agent receives paths and reads the files itself with its own tools.
const [attachments, setAttachments] = useState<string[]>([])
useEffect(() => {
setDraft('')
setAttachments([])
setStopping(false)
textareaRef.current?.focus()
}, [session.id])
useEffect(() => {
if (!busy) setStopping(false)
}, [busy])
const addAttachments = (paths: string[]) => {
const cleaned = paths.filter(Boolean)
if (cleaned.length === 0) return
setAttachments((prev) => [...prev, ...cleaned.filter((p) => !prev.includes(p))])
}
const handlePickFiles = async () => {
const res = await window.ipc.invoke('dialog:openFiles', {
title: 'Attach files',
defaultPath: session.cwd,
})
addAttachments(res.paths)
textareaRef.current?.focus()
}
const handleDrop = (e: React.DragEvent) => {
if (!e.dataTransfer?.files?.length) return
e.preventDefault()
const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
addAttachments(paths)
}
const canSend = (Boolean(draft.trim()) || attachments.length > 0) && !busy
const handleSend = async () => {
if (!canSend) return
const text = draft.trim()
const files = attachments
// The agent gets paths, CLI-style; it reads them from disk on its own.
const message = files.length > 0
? `${text || 'Look at the attached files.'}\n\nAttached files (read them from disk):\n${files.map((p) => `- ${p}`).join('\n')}`
: text
setDraft('')
setAttachments([])
const result = await send(message)
if (!result.ok && result.error) {
toast.error(result.error)
setDraft(text)
setAttachments(files)
}
}
const handleStop = async () => {
setStopping(true)
await stop()
}
const basename = (p: string) => p.split(/[\\/]/).pop() || p
return (
<div
className="flex h-full min-h-0 flex-col"
onDragOver={(e) => { if (e.dataTransfer?.types?.includes('Files')) e.preventDefault() }}
onDrop={handleDrop}
>
{/* Slim header — session controls live in the Code view's middle header */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{session.title}</div>
<div className="text-[11px] text-muted-foreground">{AGENT_LABEL[session.agent]} direct</div>
</div>
</div>
{/* Conversation */}
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4">
{loading && <div className="text-sm text-muted-foreground">Loading conversation</div>}
{!loading && items.length === 0 && !busy && (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<div className="text-sm font-medium">
Talk directly to {AGENT_LABEL[session.agent]}
</div>
<p className="max-w-sm text-xs text-muted-foreground">
Your messages go straight to the coding agent in this project. Tool calls, plans, and diffs stream in here.
</p>
</div>
)}
{items.map((item) => (
<ChatItem key={item.id} item={item} onOpenDiff={onOpenDiff} />
))}
{liveText && (
<div className="min-w-0 max-w-none break-words text-sm">
<MessageResponse>{liveText.replace(/<\/?voice>/g, '')}</MessageResponse>
</div>
)}
{pendingPermission && (
<CodeRunPermissionRequest ask={pendingPermission.ask} onDecide={(d) => void resolvePermission(d)} />
)}
{Array.from(pendingToolPermissions.values()).map((request) => (
<PermissionRequest
key={request.toolCall.toolCallId}
toolCall={request.toolCall}
permission={request.permission}
onApprove={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve')}
onApproveSession={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve', 'session')}
onApproveAlways={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve', 'always')}
onDeny={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'deny')}
isProcessing={busy}
/>
))}
{Array.from(pendingAskHumans.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
options={request.options}
onResponse={(response) => void respondToAskHuman(request.toolCallId, request.subflow, response)}
isProcessing={busy}
/>
))}
{busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && (
<Shimmer className="text-sm">
{stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`}
</Shimmer>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Composer mirrors the assistant chat input's look (rounded card,
borderless textarea, round primary send / destructive stop). */}
<div className="p-3">
<div className="rowboat-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((p) => (
<span
key={p}
title={p}
className="group inline-flex max-w-[260px] items-center gap-1.5 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-1.5 text-xs"
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{basename(p)}</span>
<button
type="button"
onClick={() => setAttachments((prev) => prev.filter((x) => x !== p))}
aria-label="Remove attachment"
className="flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="px-4 pb-2 pt-4">
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}}
placeholder="Type your message..."
className="max-h-40 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm shadow-none outline-none focus-visible:ring-0"
rows={2}
/>
</div>
<div className="flex items-center gap-2 px-3 pb-3">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void handlePickFiles()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Attach files"
>
<Plus className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Attach files the agent reads them from disk (or drag & drop)</TooltipContent>
</Tooltip>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<Terminal className="size-3.5 shrink-0" />
<span className="truncate">Direct straight to {AGENT_LABEL[session.agent]}</span>
</span>
<div className="flex-1" />
{busy ? (
<Button
size="icon"
onClick={() => void handleStop()}
title={stopping ? 'Stopping…' : 'Stop the agent'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
stopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
)}
>
{stopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={() => void handleSend()}
disabled={!canSend}
title="Send"
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSend
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground',
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,318 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useCodeSessions } from './use-code-sessions'
import { SessionRail } from './session-rail'
import { NewSessionDialog } from './new-session-dialog'
import { WorkspacePane } from './workspace-pane'
import { TerminalPane } from './terminal-pane'
const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
const TERMINAL_MIN_HEIGHT = 120
const TERMINAL_MAX_HEIGHT = 600
function readStoredTerminalHeight(): number {
if (typeof window === 'undefined') return 240
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
if (!Number.isFinite(raw) || raw <= 0) return 240
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, raw))
}
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask every time',
'auto-approve-reads': 'Auto-approve reads',
yolo: 'Auto-approve everything',
}
export interface ActiveCodeSession {
session: CodeSession
status: CodeSessionStatus
}
// The Code section's middle pane: session rail + workspace (diffs/files).
// The conversation lives in the RIGHT pane — the assistant chat bound to the
// session's run when Rowboat drives, or the direct-drive chat otherwise.
// App.tsx learns which via onSessionSelected and renders the right pane.
export function CodeView({
onSessionSelected,
openDiffPath,
onDiffOpened,
}: {
onSessionSelected?: (active: ActiveCodeSession | null) => void
// A file path the chat asked to review (clicking a changed file in a tool call).
openDiffPath?: string | null
onDiffOpened?: () => void
}) {
const { projects, sessions, statusOf, refresh } = useCodeSessions()
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
const [terminalOpen, setTerminalOpen] = useState(false)
const [terminalHeight, setTerminalHeight] = useState(readStoredTerminalHeight)
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null)
useEffect(() => {
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
}, [terminalHeight])
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
const onMove = (event: MouseEvent) => {
const drag = dragStateRef.current
if (!drag) return
// Terminal sits at the bottom: dragging up grows it.
const next = drag.startHeight + (drag.startY - event.clientY)
setTerminalHeight(Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, next)))
}
const onUp = () => {
dragStateRef.current = null
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}, [terminalHeight])
const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
// Tell App which session (and status) owns the right-hand chat pane.
useEffect(() => {
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
}, [selectedSession, selectedStatus, onSessionSelected])
// Leaving the Code section unmounts this view — release the right pane.
useEffect(() => {
return () => onSessionSelected?.(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleAddProject = useCallback(async () => {
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose a project folder' })
const dir = res.path
if (!dir) return
try {
const added = await window.ipc.invoke('codeProject:add', { path: dir })
await refresh()
setNewSessionProjectId(added.project.id)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add project')
}
}, [refresh])
const handleRemoveProject = useCallback(async (projectId: string) => {
await window.ipc.invoke('codeProject:remove', { projectId })
await refresh()
}, [refresh])
const handleSessionCreated = useCallback(async (session: CodeSession) => {
await refresh()
setSelectedSessionId(session.id)
}, [refresh])
const handleDeleteSession = useCallback(async (session: CodeSession, removeWorktree: boolean) => {
try {
await window.ipc.invoke('codeSession:delete', {
sessionId: session.id,
removeWorktree,
deleteBranch: removeWorktree,
})
if (selectedSessionId === session.id) setSelectedSessionId(null)
await refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete session')
}
}, [refresh, selectedSessionId])
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => {
if (!selectedSessionId) return
try {
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
await refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update session')
}
}, [refresh, selectedSessionId])
const busy = selectedStatus === 'working' || selectedStatus === 'needs-you'
return (
<div className="flex h-full min-h-0">
{/* Session rail */}
<div className="w-64 shrink-0 border-r">
<SessionRail
projects={projects}
sessions={sessions}
statusOf={statusOf}
selectedSessionId={selectedSessionId}
onSelectSession={setSelectedSessionId}
onAddProject={() => void handleAddProject()}
onRemoveProject={(id) => void handleRemoveProject(id)}
onNewSession={setNewSessionProjectId}
onDeleteSession={setDeleteTarget}
/>
</div>
{/* Workspace: session header + diffs/files. The chat is in the right pane. */}
<div className="flex min-w-0 flex-1 flex-col">
{selectedSession ? (
<>
<div className="flex items-center gap-3 border-b px-4 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{AGENT_LABEL[selectedSession.agent]}</span>
<span>·</span>
<span className="truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
<GitBranch className="size-3" />
{selectedSession.worktree.branch}
</span>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground">
{POLICY_LABEL[selectedSession.policy]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
{POLICY_LABEL[policy]}
{selectedSession.policy === policy && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Bot className="size-3.5" />
Rowboat drives
<Switch
checked={selectedSession.mode === 'rowboat'}
disabled={busy}
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
/>
</label>
</div>
<div className="min-h-0 flex-1">
<WorkspacePane
session={selectedSession}
status={selectedStatus}
openDiffPath={openDiffPath ?? null}
onDiffOpened={() => onDiffOpened?.()}
onSessionChanged={() => void refresh()}
/>
</div>
{/* Embedded terminal a real shell in the session's directory
(worktree included). The PTY lives in the main process and
survives collapsing this panel. */}
<div className="shrink-0 border-t">
{terminalOpen && (
<div
onMouseDown={handleTerminalDragStart}
className="h-1 cursor-row-resize bg-transparent transition-colors hover:bg-sidebar-border"
/>
)}
<button
type="button"
onClick={() => setTerminalOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<TerminalIcon className="size-3.5" />
<span className="font-medium">Terminal</span>
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px]">worktree</span>
)}
<span className="flex-1" />
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
</button>
{terminalOpen && (
<div style={{ height: terminalHeight }}>
<TerminalPane
key={selectedSession.id}
terminalId={selectedSession.id}
cwd={selectedSession.cwd}
/>
</div>
)}
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<Code2 className="size-10 text-muted-foreground/40" />
<div className="text-sm font-medium">Code with agents</div>
<p className="max-w-sm px-6 text-xs text-muted-foreground">
Run Claude Code or Codex on your projects let Rowboat drive them, or talk to them
directly. The conversation happens in the chat pane on the right; changes and files
show here.
</p>
{projects.length === 0 ? (
<Button size="sm" onClick={() => void handleAddProject()}>Add a project to get started</Button>
) : (
<p className="text-xs text-muted-foreground">Pick a session on the left, or create a new one.</p>
)}
</div>
)}
</div>
<NewSessionDialog
projectRow={newSessionProject}
open={newSessionProjectId !== null}
onOpenChange={(open) => { if (!open) setNewSessionProjectId(null) }}
onCreated={(session) => void handleSessionCreated(session)}
/>
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this session?</AlertDialogTitle>
<AlertDialogDescription>
The conversation history will be deleted.
{deleteTarget?.worktree && !deleteTarget.worktree.removedAt
? ' Its worktree and branch will be removed too — merge back first if you want to keep the changes.'
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteTarget) void handleDeleteSession(deleteTarget, true)
setDeleteTarget(null)
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import { MergeView, unifiedMergeView } from '@codemirror/merge'
import { EditorView } from '@codemirror/view'
import { Columns2, FoldVertical, Rows2, UnfoldVertical, X } from 'lucide-react'
import { useTheme } from '@/contexts/theme-context'
import { Button } from '@/components/ui/button'
import { cmBaseExtensions, cmLanguageFor } from './cm'
// Read-only diff of one file's working-tree changes vs HEAD, side-by-side or
// unified. Content comes from codeSession:fileDiff (old = git show HEAD:path,
// new = disk).
export function DiffViewer({
sessionId,
path,
onClose,
}: {
sessionId: string
path: string
onClose: () => void
}) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const containerRef = useRef<HTMLDivElement>(null)
const [mode, setMode] = useState<'split' | 'unified'>('split')
// GitHub-style: unchanged regions fold into "⋯ N lines" bars (each clickable
// to reveal); "Expand all" rebuilds the view with nothing collapsed.
const [collapseUnchanged, setCollapseUnchanged] = useState(true)
const [diff, setDiff] = useState<{ oldText: string; newText: string; isBinary: boolean; tooLarge: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setDiff(null)
setError(null)
window.ipc.invoke('codeSession:fileDiff', { sessionId, path })
.then((res) => { if (!cancelled) setDiff(res) })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load diff') })
return () => { cancelled = true }
}, [sessionId, path])
useEffect(() => {
const parent = containerRef.current
if (!parent || !diff || diff.isBinary || diff.tooLarge) return
let view: MergeView | EditorView | null = null
let cancelled = false
void cmLanguageFor(path).then((language) => {
if (cancelled || !containerRef.current) return
const extensions = [...cmBaseExtensions(isDark), ...(language ? [language] : [])]
// Same context margins GitHub uses: keep a few lines around each hunk,
// only fold stretches long enough to be worth hiding.
const collapse = collapseUnchanged ? { margin: 3, minSize: 6 } : undefined
if (mode === 'split') {
view = new MergeView({
a: { doc: diff.oldText, extensions },
b: { doc: diff.newText, extensions },
parent,
gutter: true,
...(collapse ? { collapseUnchanged: collapse } : {}),
})
} else {
view = new EditorView({
doc: diff.newText,
extensions: [
...extensions,
unifiedMergeView({
original: diff.oldText,
mergeControls: false,
...(collapse ? { collapseUnchanged: collapse } : {}),
}),
],
parent,
})
}
})
return () => {
cancelled = true
view?.destroy()
}
}, [diff, mode, isDark, path, collapseUnchanged])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90" title={path}>{path}</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setCollapseUnchanged((c) => !c)}
title={collapseUnchanged ? 'Show the whole file' : 'Collapse unchanged regions'}
>
{collapseUnchanged ? <UnfoldVertical className="size-3.5" /> : <FoldVertical className="size-3.5" />}
{collapseUnchanged ? 'Expand all' : 'Collapse'}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setMode((m) => (m === 'split' ? 'unified' : 'split'))}
title={mode === 'split' ? 'Switch to unified view' : 'Switch to side-by-side view'}
>
{mode === 'split' ? <Rows2 className="size-3.5" /> : <Columns2 className="size-3.5" />}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close diff">
<X className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
{!error && !diff && <div className="p-4 text-sm text-muted-foreground">Loading diff</div>}
{diff?.isBinary && <div className="p-4 text-sm text-muted-foreground">Binary file no text diff.</div>}
{diff?.tooLarge && <div className="p-4 text-sm text-muted-foreground">File too large to diff here.</div>}
{diff && !diff.isBinary && !diff.tooLarge && (
<div ref={containerRef} className="h-full [&_.cm-mergeView]:h-full [&_.cm-editor]:h-full" />
)}
</div>
</div>
)
}

View file

@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react'
import { cn } from '@/lib/utils'
interface TreeEntry {
name: string
kind: 'file' | 'dir'
}
// Lazy file tree over codeSession:readdir — one directory level per request,
// so big folders (node_modules) cost nothing until expanded.
export function CodeFileTree({
sessionId,
selectedPath,
onSelectFile,
}: {
sessionId: string
selectedPath: string | null
onSelectFile: (relPath: string) => void
}) {
const [childrenByDir, setChildrenByDir] = useState<Record<string, TreeEntry[]>>({})
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const loadDir = useCallback(async (relPath: string) => {
try {
const res = await window.ipc.invoke('codeSession:readdir', { sessionId, relPath })
setChildrenByDir((prev) => ({ ...prev, [relPath]: res.entries }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to read directory')
}
}, [sessionId])
useEffect(() => {
setChildrenByDir({})
setExpanded(new Set())
setError(null)
void loadDir('.')
}, [loadDir])
const toggleDir = (relPath: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relPath)) {
next.delete(relPath)
} else {
next.add(relPath)
if (!childrenByDir[relPath]) void loadDir(relPath)
}
return next
})
}
const renderDir = (relPath: string, depth: number) => {
const entries = childrenByDir[relPath]
if (!entries) {
return <div className="px-2 py-1 text-xs text-muted-foreground" style={{ paddingLeft: depth * 12 + 8 }}>Loading</div>
}
return entries.map((entry) => {
const childPath = relPath === '.' ? entry.name : `${relPath}/${entry.name}`
if (entry.kind === 'dir') {
const isOpen = expanded.has(childPath)
return (
<div key={childPath}>
<button
type="button"
onClick={() => toggleDir(childPath)}
className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted"
style={{ paddingLeft: depth * 12 + 8 }}
>
{isOpen ? <ChevronDown className="size-3 shrink-0" /> : <ChevronRight className="size-3 shrink-0" />}
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
</button>
{isOpen && renderDir(childPath, depth + 1)}
</div>
)
}
return (
<button
key={childPath}
type="button"
onClick={() => onSelectFile(childPath)}
className={cn(
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted',
selectedPath === childPath && 'bg-muted font-medium',
)}
style={{ paddingLeft: depth * 12 + 22 }}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
</button>
)
})
}
if (error) {
return <div className="p-3 text-xs text-destructive">{error}</div>
}
return <div className="overflow-auto py-1">{renderDir('.', 0)}</div>
}

View file

@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from 'react'
import { EditorView } from '@codemirror/view'
import { X } from 'lucide-react'
import { useTheme } from '@/contexts/theme-context'
import { Button } from '@/components/ui/button'
import { cmBaseExtensions, cmLanguageFor } from './cm'
// Read-only, syntax-highlighted view of one file in the session directory.
export function CodeFileViewer({
sessionId,
path,
onClose,
}: {
sessionId: string
path: string
onClose: () => void
}) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const containerRef = useRef<HTMLDivElement>(null)
const [file, setFile] = useState<{ content: string; isBinary: boolean; tooLarge: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setFile(null)
setError(null)
window.ipc.invoke('codeSession:readFile', { sessionId, relPath: path })
.then((res) => { if (!cancelled) setFile(res) })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to read file') })
return () => { cancelled = true }
}, [sessionId, path])
useEffect(() => {
const parent = containerRef.current
if (!parent || !file || file.isBinary || file.tooLarge) return
let view: EditorView | null = null
let cancelled = false
void cmLanguageFor(path).then((language) => {
if (cancelled || !containerRef.current) return
view = new EditorView({
doc: file.content,
extensions: [...cmBaseExtensions(isDark), ...(language ? [language] : [])],
parent,
})
})
return () => {
cancelled = true
view?.destroy()
}
}, [file, isDark, path])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90" title={path}>{path}</span>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close file">
<X className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
{!error && !file && <div className="p-4 text-sm text-muted-foreground">Loading</div>}
{file?.isBinary && <div className="p-4 text-sm text-muted-foreground">Binary file.</div>}
{file?.tooLarge && <div className="p-4 text-sm text-muted-foreground">File too large to preview.</div>}
{file && !file.isBinary && !file.tooLarge && <div ref={containerRef} className="h-full [&_.cm-editor]:h-full" />}
</div>
</div>
)
}

View file

@ -0,0 +1,315 @@
import { useEffect, useState } from 'react'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ProjectRow } from './use-code-sessions'
type AgentStatus = { installed: boolean; signedIn: boolean }
type ModelOption = { provider: string; model: string }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask every time',
'auto-approve-reads': 'Auto-approve reads',
yolo: 'Auto-approve everything (YOLO)',
}
// Models the user can pick for Rowboat-mode turns — mirrors the chat
// composer's loading: gateway list when signed in, models.json otherwise.
async function loadModelOptions(): Promise<ModelOption[]> {
try {
const oauth = await window.ipc.invoke('oauth:getState', null)
const connected = oauth.config?.rowboat?.connected ?? false
if (connected) {
const listResult = await window.ipc.invoke('models:list', null)
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
?.find((p) => p.id === 'rowboat')
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
}
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
const models: ModelOption[] = []
if (parsed?.providers) {
for (const [flavor, entry] of Object.entries(parsed.providers)) {
const e = entry as Record<string, unknown>
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
const singleModel = typeof e.model === 'string' ? e.model : ''
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) models.push({ provider: flavor, model })
}
}
}
return models
} catch {
return []
}
}
export function NewSessionDialog({
projectRow,
open,
onOpenChange,
onCreated,
}: {
projectRow: ProjectRow | null
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (session: CodeSession) => void
}) {
const [agentStatus, setAgentStatus] = useState<{ claude: AgentStatus; codex: AgentStatus } | null>(null)
const [agent, setAgent] = useState<CodingAgent>('claude')
// Rowboat drives by default — direct CLI access is the power-user opt-in.
const [mode, setMode] = useState<CodeSessionMode>('rowboat')
const [policy, setPolicy] = useState<ApprovalPolicy>('auto-approve-reads')
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
const [title, setTitle] = useState('')
const [creating, setCreating] = useState(false)
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
// 'default' = let the backend use the configured default model.
const [modelKey, setModelKey] = useState('default')
const git = projectRow?.git
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
useEffect(() => {
if (!open) return
setTitle('')
setCreating(false)
setIsolation('in-repo')
setMode('rowboat')
setModelKey('default')
void loadModelOptions().then(setModelOptions)
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
setAgentStatus(status)
// Default to whichever agent is actually ready.
const claudeReady = status.claude.installed && status.claude.signedIn
const codexReady = status.codex.installed && status.codex.signedIn
if (!claudeReady && codexReady) setAgent('codex')
else setAgent('claude')
})
}, [open])
const agentReady = (a: CodingAgent): boolean => {
if (!agentStatus) return true
const s = agentStatus[a]
return s.installed && s.signedIn
}
const handleCreate = async () => {
if (!projectRow) return
setCreating(true)
try {
const picked = modelKey !== 'default'
? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey)
: undefined
const res = await window.ipc.invoke('codeSession:create', {
projectId: projectRow.project.id,
title: title.trim() || undefined,
agent,
mode,
policy,
isolation,
...(picked ? { model: picked.model, provider: picked.provider } : {}),
})
onOpenChange(false)
onCreated(res.session)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create session')
} finally {
setCreating(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New coding session</DialogTitle>
<DialogDescription>
{projectRow ? <span className="font-mono text-xs">{projectRow.project.path}</span> : null}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Name (optional)</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Fix flaky auth tests"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Coding agent</label>
<div className="grid grid-cols-2 gap-2">
{(['claude', 'codex'] as const).map((a) => {
const ready = agentReady(a)
return (
<button
key={a}
type="button"
disabled={!ready}
onClick={() => setAgent(a)}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
agent === a ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
!ready && 'cursor-not-allowed opacity-50',
)}
>
<div className="font-medium">{a === 'claude' ? 'Claude Code' : 'Codex'}</div>
<div className="text-[11px] text-muted-foreground">
{ready ? 'Ready' : agentStatus?.[a]?.installed ? 'Not signed in' : 'Enable in Settings'}
</div>
</button>
)
})}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Who drives</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setMode('rowboat')}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
mode === 'rowboat' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
)}
>
<div className="flex items-center gap-1.5 font-medium">
<Bot className="size-3.5" />
Rowboat
</div>
<div className="text-[11px] text-muted-foreground">
Full assistant chat Rowboat plans, runs the agent, and can use your knowledge.
</div>
</button>
<button
type="button"
onClick={() => setMode('direct')}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
mode === 'direct' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
)}
>
<div className="flex items-center gap-1.5 font-medium">
<Terminal className="size-3.5" />
Direct
</div>
<div className="text-[11px] text-muted-foreground">
Talk straight to the coding agent no assistant in between.
</div>
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Where it works</label>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setIsolation('in-repo')}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
isolation === 'in-repo' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
)}
>
<div className="font-medium">Directly in the project</div>
<div className="text-[11px] text-muted-foreground">Changes land in your working tree.</div>
</button>
<button
type="button"
disabled={!worktreeAvailable}
onClick={() => setIsolation('worktree')}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
isolation === 'worktree' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
!worktreeAvailable && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-1.5 font-medium">
<GitBranch className="size-3.5" />
Isolated worktree
</div>
<div className="text-[11px] text-muted-foreground">
{worktreeAvailable
? 'Works on its own branch — safe to run sessions in parallel; merge back when done.'
: 'Needs a git repository with at least one commit.'}
</div>
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Approvals</label>
<Select value={policy} onValueChange={(v) => setPolicy(v as ApprovalPolicy)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((p) => (
<SelectItem key={p} value={p}>{POLICY_LABEL[p]}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
How the coding agent's file edits and commands get approved applies in both modes.
</p>
</div>
{/* The model only powers Rowboat's own turns; the coding agent uses its
own configured model, so hide this entirely for direct sessions. */}
{mode === 'rowboat' && modelOptions.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={modelKey} onValueChange={setModelKey}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default model</SelectItem>
{modelOptions.map((m) => {
const key = `${m.provider}/${m.model}`
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
})}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Used when Rowboat drives. Fixed once the session is created, like any chat.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={() => void handleCreate()} disabled={creating || !projectRow || !agentReady(agent)}>
{creating && <Loader2 className="size-4 animate-spin" />}
Create session
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,132 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
// Mirror of ChatSidebar's resize behavior for the direct-mode code chat pane:
// same bounds, same drag handle, and the SAME persisted width key — so the
// assistant pane and the direct pane stay the same size as the user switches
// between session modes.
const MIN_WIDTH = 360
const MAX_WIDTH = 1600
const MIN_MAIN_PANE_WIDTH = 420
const MIN_MAIN_PANE_RATIO = 0.3
const RIGHT_PANE_WIDTH_STORAGE_KEY = 'x:right-pane-width'
function clampPaneWidth(width: number, maxWidth: number = MAX_WIDTH): number {
const boundedMax = Math.max(0, Math.min(MAX_WIDTH, maxWidth))
const boundedMin = Math.min(MIN_WIDTH, boundedMax)
return Math.min(boundedMax, Math.max(boundedMin, width))
}
function readStoredWidth(defaultWidth: number): number {
const fallback = clampPaneWidth(defaultWidth)
if (typeof window === 'undefined') return fallback
try {
const raw = window.localStorage.getItem(RIGHT_PANE_WIDTH_STORAGE_KEY)
if (!raw) return fallback
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return fallback
return clampPaneWidth(parsed)
} catch {
return fallback
}
}
export function ResizableRightPane({
defaultWidth = 460,
className,
children,
onActivate,
}: {
defaultWidth?: number
className?: string
children: React.ReactNode
/** Fired on any mouse-down inside the pane (keyboard-shortcut focus tracking). */
onActivate?: () => void
}) {
const paneRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(() => readStoredWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
// Never let the pane squeeze the main content below a usable width.
const getMaxAllowedWidth = useCallback(() => {
if (typeof window === 'undefined') return MAX_WIDTH
const paneElement = paneRef.current
const splitContainer = paneElement?.parentElement
const mainPane = splitContainer?.querySelector<HTMLElement>('[data-slot="sidebar-inset"]')
const paneWidth = paneElement?.getBoundingClientRect().width ?? 0
const mainPaneWidth = mainPane?.getBoundingClientRect().width ?? 0
const splitWidth = paneWidth + mainPaneWidth
const fallbackWidth = splitContainer?.clientWidth ?? window.innerWidth
const availableSplitWidth = splitWidth > 0 ? splitWidth : fallbackWidth
const minMainPaneWidth = Math.min(
availableSplitWidth,
Math.max(MIN_MAIN_PANE_WIDTH, Math.floor(availableSplitWidth * MIN_MAIN_PANE_RATIO)),
)
return Math.max(0, availableSplitWidth - minMainPaneWidth)
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(RIGHT_PANE_WIDTH_STORAGE_KEY, String(width))
} catch {
// keep in-memory width on persistence failure
}
}, [width])
useEffect(() => {
const clampToAvailableWidth = () => {
const maxAllowedWidth = getMaxAllowedWidth()
setWidth((prev) => clampPaneWidth(prev, maxAllowedWidth))
}
clampToAvailableWidth()
window.addEventListener('resize', clampToAvailableWidth)
return () => window.removeEventListener('resize', clampToAvailableWidth)
}, [getMaxAllowedWidth])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
startXRef.current = e.clientX
startWidthRef.current = width
setIsResizing(true)
const handleMouseMove = (event: MouseEvent) => {
// Pane sits on the right: dragging left grows it.
const delta = startXRef.current - event.clientX
const maxAllowedWidth = getMaxAllowedWidth()
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
}
const handleMouseUp = () => {
setIsResizing(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [width, getMaxAllowedWidth])
return (
<div
ref={paneRef}
onMouseDownCapture={onActivate}
className={cn(
'relative flex min-h-0 min-w-0 shrink-0 flex-col overflow-hidden border-l border-border bg-background',
className,
)}
style={{ width, flex: '0 0 auto' }}
>
<div
onMouseDown={handleMouseDown}
className={cn(
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
'hover:after:bg-sidebar-border',
isResizing && 'after:bg-primary',
)}
/>
{children}
</div>
)
}

View file

@ -0,0 +1,179 @@
import { FolderGit2, FolderPlus, MoreHorizontal, Plus, Trash2 } from 'lucide-react'
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { ProjectRow } from './use-code-sessions'
function StatusDot({ status }: { status: CodeSessionStatus }) {
if (status === 'needs-you') {
return <span className="size-2 shrink-0 animate-pulse rounded-full bg-amber-500" title="Needs your attention" />
}
if (status === 'working') {
return <span className="size-2 shrink-0 animate-pulse rounded-full bg-blue-500" title="Working" />
}
return <span className="size-2 shrink-0 rounded-full bg-muted-foreground/30" title="Idle" />
}
const AGENT_SHORT: Record<string, string> = { claude: 'Claude', codex: 'Codex' }
// Left rail: registered projects with their sessions, attention-first.
export function SessionRail({
projects,
sessions,
statusOf,
selectedSessionId,
onSelectSession,
onAddProject,
onRemoveProject,
onNewSession,
onDeleteSession,
}: {
projects: ProjectRow[]
sessions: CodeSession[]
statusOf: (sessionId: string) => CodeSessionStatus
selectedSessionId: string | null
onSelectSession: (sessionId: string) => void
onAddProject: () => void
onRemoveProject: (projectId: string) => void
onNewSession: (projectId: string) => void
onDeleteSession: (session: CodeSession) => void
}) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Projects</span>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onAddProject}>
<FolderPlus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add a project folder</TooltipContent>
</Tooltip>
</div>
<div className="min-h-0 flex-1 overflow-auto px-2 pb-2">
{projects.length === 0 && (
<div className="flex flex-col items-center gap-3 px-3 py-10 text-center">
<FolderGit2 className="size-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
Add a project folder to start running coding agents on it.
</p>
<Button size="sm" variant="outline" onClick={onAddProject}>
<FolderPlus className="size-3.5" />
Add project
</Button>
</div>
)}
{projects.map(({ project }) => {
const projectSessions = sessions.filter((s) => s.projectId === project.id)
return (
<div key={project.id} className="mb-3">
<div className="group flex items-center gap-1.5 px-1 py-1">
{/* Deliberate hover delay the full path is reference info,
not something that should pop up on a passing cursor. */}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<FolderGit2 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{project.name}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[420px] break-all font-mono text-xs">
{project.path}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => onNewSession(project.id)}
title="New session"
>
<Plus className="size-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onRemoveProject(project.id)}>
<Trash2 className="size-4" />
Remove project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{projectSessions.length === 0 ? (
<button
type="button"
onClick={() => onNewSession(project.id)}
className="ml-5 flex items-center gap-1.5 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
>
<Plus className="size-3" />
New session
</button>
) : (
projectSessions.map((session) => {
const status = statusOf(session.id)
return (
<div
key={session.id}
className={cn(
'group ml-3 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5',
selectedSessionId === session.id ? 'bg-muted' : 'hover:bg-muted/60',
)}
onClick={() => onSelectSession(session.id)}
>
<StatusDot status={status} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs">{session.title}</div>
<div className="truncate text-[10px] text-muted-foreground">
{AGENT_SHORT[session.agent]}
{session.mode === 'rowboat' ? ' · Rowboat drives' : ''}
{session.worktree && !session.worktree.removedAt ? ' · worktree' : ''}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => onDeleteSession(session)}>
<Trash2 className="size-4" />
Delete session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,110 @@
import { useEffect, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { useTheme } from '@/contexts/theme-context'
// xterm color schemes tuned to the app's light/dark backgrounds.
const DARK_THEME = {
background: '#1b1b1f',
foreground: '#d4d4d8',
cursor: '#d4d4d8',
selectionBackground: 'rgba(120, 140, 255, 0.3)',
}
const LIGHT_THEME = {
background: '#ffffff',
foreground: '#27272a',
cursor: '#27272a',
selectionBackground: 'rgba(60, 90, 220, 0.2)',
}
// One embedded terminal view, attached to a per-session PTY in the main
// process. The PTY outlives this component (collapse/switch just detaches);
// on mount we re-attach and repaint from the backlog the main process keeps.
export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: string }) {
const { resolvedTheme } = useTheme()
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<Terminal | null>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
const term = new Terminal({
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
cursorBlink: true,
scrollback: 5000,
theme: resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(container)
fit.fit()
termRef.current = term
let disposed = false
// Attach (or spawn) the PTY at the current size, then repaint history.
void window.ipc.invoke('terminal:ensure', {
id: terminalId,
cwd,
cols: term.cols,
rows: term.rows,
}).then(({ backlog }) => {
if (disposed) return
if (backlog) term.write(backlog)
term.focus()
})
const dataDisposable = term.onData((data) => {
void window.ipc.invoke('terminal:input', { id: terminalId, data })
})
const offData = window.ipc.on('terminal:data', (payload) => {
if (payload.id === terminalId) term.write(payload.data)
})
const offExit = window.ipc.on('terminal:exit', (payload) => {
if (payload.id !== terminalId) return
term.write(`\r\n\x1b[2m[process exited with code ${payload.exitCode} — press Enter to restart]\x1b[0m\r\n`)
})
// Restart the shell on Enter after it exited (ensure() respawns dead PTYs).
const keyDisposable = term.onKey(({ domEvent }) => {
if (domEvent.key !== 'Enter') return
void window.ipc.invoke('terminal:ensure', {
id: terminalId,
cwd,
cols: term.cols,
rows: term.rows,
})
})
const resizeObserver = new ResizeObserver(() => {
if (container.clientHeight === 0) return
fit.fit()
void window.ipc.invoke('terminal:resize', { id: terminalId, cols: term.cols, rows: term.rows })
})
resizeObserver.observe(container)
return () => {
disposed = true
resizeObserver.disconnect()
offData()
offExit()
dataDisposable.dispose()
keyDisposable.dispose()
term.dispose()
termRef.current = null
}
// The PTY is keyed by terminalId; cwd changes (worktree cleanup) respawn via ensure.
}, [terminalId, cwd])
// Live theme switches restyle the existing terminal without a respawn.
useEffect(() => {
const term = termRef.current
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
}, [resolvedTheme])
return <div ref={containerRef} className="h-full w-full overflow-hidden px-2 pt-1" />
}

View file

@ -0,0 +1,473 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type z from 'zod'
import type { RunEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
import type { CodeSession } from '@x/shared/src/code-sessions.js'
import {
type ChatMessage,
type ErrorMessage,
type ToolCall,
normalizeToolInput,
} from '@/lib/chat-conversation'
// A direct-drive coding turn: the structural ACP events (tool calls, plan,
// resolved permissions) grouped under one turn id. The agent's prose is NOT
// part of the turn — it streams via liveText and lands as an assistant
// ChatMessage, so live rendering and JSONL replay converge on the same shape.
export interface DirectTurn {
kind: 'direct-turn'
id: string
events: CodeRunEvent[]
timestamp: number
}
export type CodeChatItem = ChatMessage | ToolCall | ErrorMessage | DirectTurn
export const isDirectTurn = (item: CodeChatItem): item is DirectTurn =>
'kind' in item && (item as DirectTurn).kind === 'direct-turn'
// Narrowing guards over the widened item union (the chat-conversation guards
// only accept ConversationItem).
export const isChatToolCall = (item: CodeChatItem): item is ToolCall => 'name' in item
export const isChatErrorMessage = (item: CodeChatItem): item is ErrorMessage =>
'kind' in item && (item as ErrorMessage).kind === 'error'
export const isChatMessageItem = (item: CodeChatItem): item is ChatMessage => 'role' in item
export interface PendingCodePermission {
requestId: string
ask: PermissionAsk
toolCallId: string
}
const DIRECT_PREFIX = 'direct-'
const STRUCTURAL_EVENTS = new Set(['tool_call', 'tool_call_update', 'plan', 'permission'])
function messageText(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return (content as Array<{ type: string; text?: string }>)
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
}
return ''
}
// Conversation state for one coding session, fed by the run JSONL (history)
// and the live runs:events stream. Handles both modes: direct turns arrive as
// code-run-events with a `direct-` toolCallId; Rowboat turns arrive as the
// usual LLM message/tool events (incl. code_agent_run blocks).
export function useCodeChat(session: CodeSession | null) {
const sessionId = session?.id ?? null
const [items, setItems] = useState<CodeChatItem[]>([])
const [liveText, setLiveText] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [pendingPermission, setPendingPermission] = useState<PendingCodePermission | null>(null)
// Rowboat-mode copilot gates, same as the main chat: pre-tool-call permission
// requests and ask-human questions. Keyed by toolCallId.
const [pendingToolPermissions, setPendingToolPermissions] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
const [pendingAskHumans, setPendingAskHumans] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
const [loading, setLoading] = useState(false)
const seenMessageIdsRef = useRef<Set<string>>(new Set())
const applyCodeRunEvent = useCallback((toolCallId: string, event: CodeRunEvent) => {
if (toolCallId.startsWith(DIRECT_PREFIX)) {
if (!STRUCTURAL_EVENTS.has(event.type)) return
setItems((prev) => {
const at = prev.findIndex((item) => isDirectTurn(item) && item.id === toolCallId)
if (at >= 0) {
const turn = prev[at] as DirectTurn
const next = [...prev]
next[at] = { ...turn, events: [...turn.events, event] }
return next
}
return [...prev, { kind: 'direct-turn', id: toolCallId, events: [event], timestamp: Date.now() }]
})
return
}
// Rowboat mode: attach to the code_agent_run tool call block.
setItems((prev) => prev.map((item) => {
if (isChatToolCall(item) && item.id === toolCallId) {
return { ...item, codeRunEvents: [...(item.codeRunEvents ?? []), event] }
}
return item
}))
}, [])
// Load history from the run log whenever the session changes.
useEffect(() => {
if (!sessionId) {
setItems([])
setLiveText('')
setPendingPermission(null)
return
}
let cancelled = false
setLoading(true)
setItems([])
setLiveText('')
setPendingPermission(null)
setPendingToolPermissions(new Map())
setPendingAskHumans(new Map())
seenMessageIdsRef.current = new Set()
void window.ipc.invoke('runs:fetch', { runId: sessionId }).then((run) => {
if (cancelled) return
const loaded: CodeChatItem[] = []
const toolCallMap = new Map<string, ToolCall>()
const turnMap = new Map<string, DirectTurn>()
// Rebuild copilot gates still waiting on the user (request without a
// matching response in the log) so reopening a blocked session shows them.
const toolPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
const askHumans = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
for (const event of run.log as z.infer<typeof RunEvent>[]) {
const ts = event.ts ? new Date(event.ts).getTime() : Date.now()
switch (event.type) {
case 'message': {
const msg = event.message
if (msg.role === 'user' || msg.role === 'assistant') {
const text = messageText(msg.content)
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
for (const part of msg.content as Array<{ type: string; toolCallId?: string; toolName?: string; arguments?: unknown }>) {
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = {
id: part.toolCallId,
name: part.toolName,
input: normalizeToolInput(part.arguments as ToolCall['input']),
status: 'pending',
timestamp: ts,
}
toolCallMap.set(toolCall.id, toolCall)
loaded.push(toolCall)
}
}
}
if (text.trim()) {
seenMessageIdsRef.current.add(event.messageId)
loaded.push({ id: event.messageId, role: msg.role, content: text, timestamp: ts })
}
}
break
}
case 'tool-invocation': {
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
if (existing) {
existing.input = normalizeToolInput(event.input)
existing.status = 'running'
}
break
}
case 'tool-result': {
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
if (existing) {
existing.result = event.result as ToolCall['result']
existing.status = 'completed'
}
break
}
case 'code-run-event': {
if (event.toolCallId.startsWith(DIRECT_PREFIX)) {
if (!STRUCTURAL_EVENTS.has(event.event.type)) break
let turn = turnMap.get(event.toolCallId)
if (!turn) {
turn = { kind: 'direct-turn', id: event.toolCallId, events: [], timestamp: ts }
turnMap.set(event.toolCallId, turn)
loaded.push(turn)
}
turn.events.push(event.event)
} else {
const existing = toolCallMap.get(event.toolCallId)
if (existing) existing.codeRunEvents = [...(existing.codeRunEvents ?? []), event.event]
}
break
}
case 'tool-permission-request':
toolPerms.set(event.toolCall.toolCallId, event)
break
case 'tool-permission-response':
toolPerms.delete(event.toolCallId)
break
case 'ask-human-request':
askHumans.set(event.toolCallId, event)
break
case 'ask-human-response':
askHumans.delete(event.toolCallId)
break
case 'run-stopped':
toolPerms.clear()
askHumans.clear()
break
case 'error':
loaded.push({ id: `error-${loaded.length}`, kind: 'error', message: event.error, timestamp: ts })
break
default:
break
}
}
setItems(loaded)
setPendingToolPermissions(toolPerms)
setPendingAskHumans(askHumans)
}).catch(() => {
// Run log unreadable — show an empty conversation rather than crashing.
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [sessionId])
// Live event stream.
useEffect(() => {
if (!sessionId) return
// runs:events is schema-less on the wire (req: z.null()) — cast like App.tsx does.
return window.ipc.on('runs:events', ((raw: unknown) => {
const event = raw as z.infer<typeof RunEvent>
if (event.runId !== sessionId) return
switch (event.type) {
case 'run-processing-start':
setIsProcessing(true)
break
case 'run-processing-end':
setIsProcessing(false)
setPendingPermission(null)
// Anything still streaming that never landed as a message (e.g. the
// turn errored) is flushed so the text isn't lost.
setLiveText((text) => {
if (text.trim()) {
setItems((prev) => [...prev, {
id: `assistant-flush-${Date.now()}`,
role: 'assistant',
content: text,
timestamp: Date.now(),
}])
}
return ''
})
break
case 'run-stopped':
setIsProcessing(false)
setPendingPermission(null)
setPendingToolPermissions(new Map())
setPendingAskHumans(new Map())
break
case 'tool-permission-request':
setPendingToolPermissions((prev) => new Map(prev).set(event.toolCall.toolCallId, event))
break
case 'tool-permission-response':
setPendingToolPermissions((prev) => {
const next = new Map(prev)
next.delete(event.toolCallId)
return next
})
break
case 'ask-human-request':
setPendingAskHumans((prev) => new Map(prev).set(event.toolCallId, event))
break
case 'ask-human-response':
setPendingAskHumans((prev) => {
const next = new Map(prev)
next.delete(event.toolCallId)
return next
})
break
case 'message': {
const msg = event.message
if (msg.role !== 'user' && msg.role !== 'assistant') break
if (seenMessageIdsRef.current.has(event.messageId)) break
const text = messageText(msg.content)
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
for (const part of msg.content as Array<{ type: string; toolCallId?: string; toolName?: string; arguments?: unknown }>) {
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = {
id: part.toolCallId,
name: part.toolName,
input: normalizeToolInput(part.arguments as ToolCall['input']),
status: 'running',
timestamp: Date.now(),
}
setItems((prev) => (prev.some((i) => isChatToolCall(i) && i.id === toolCall.id) ? prev : [...prev, toolCall]))
}
}
}
if (!text.trim()) break
seenMessageIdsRef.current.add(event.messageId)
const chatMessage: ChatMessage = {
id: event.messageId,
role: msg.role,
content: text.replace(/<\/?voice>/g, ''),
timestamp: Date.now(),
}
if (msg.role === 'assistant') setLiveText('')
setItems((prev) => {
// Replace the optimistic local echo of this user message if present.
if (msg.role === 'user') {
const at = prev.findIndex((item) =>
'role' in item && item.role === 'user' && item.id.startsWith('local-') && item.content === text)
if (at >= 0) {
const next = [...prev]
next[at] = chatMessage
return next
}
}
return [...prev, chatMessage]
})
break
}
case 'llm-stream-event': {
// Rowboat mode streaming text.
const llmEvent = event.event as { type: string; delta?: string; toolCallId?: string; toolName?: string; input?: unknown }
setIsProcessing(true)
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
setLiveText((prev) => prev + llmEvent.delta)
} else if (llmEvent.type === 'tool-call' && llmEvent.toolCallId) {
const toolCall: ToolCall = {
id: llmEvent.toolCallId,
name: llmEvent.toolName || 'tool',
input: normalizeToolInput(llmEvent.input as ToolCall['input']),
status: 'running',
timestamp: Date.now(),
}
setItems((prev) => (prev.some((i) => isChatToolCall(i) && i.id === toolCall.id) ? prev : [...prev, toolCall]))
}
break
}
case 'tool-invocation':
setItems((prev) => prev.map((item) => (
isChatToolCall(item) && item.id === event.toolCallId
? { ...item, input: normalizeToolInput(event.input), status: 'running' as const }
: item
)))
break
case 'tool-result':
setItems((prev) => prev.map((item) => (
isChatToolCall(item) && item.id === event.toolCallId
? { ...item, result: event.result as ToolCall['result'], status: 'completed' as const, pendingCodePermission: null }
: item
)))
break
case 'code-run-event': {
setIsProcessing(true)
if (event.event.type === 'message' && event.event.role === 'agent' && event.toolCallId.startsWith(DIRECT_PREFIX)) {
const text = event.event.text
setLiveText((prev) => prev + text)
}
if (event.event.type === 'permission') {
setPendingPermission(null)
}
applyCodeRunEvent(event.toolCallId, event.event)
break
}
case 'code-run-permission-request':
setPendingPermission({ requestId: event.requestId, ask: event.ask, toolCallId: event.toolCallId })
break
case 'error':
setItems((prev) => [...prev, {
id: `error-${Date.now()}`,
kind: 'error',
message: event.error,
timestamp: Date.now(),
}])
break
default:
break
}
}) as unknown as (event: null) => void)
}, [sessionId, applyCodeRunEvent])
const send = useCallback(async (text: string): Promise<{ ok: boolean; error?: string }> => {
if (!session) return { ok: false, error: 'No session selected' }
const trimmed = text.trim()
if (!trimmed) return { ok: false }
// Optimistic echo, replaced by the persisted event when it arrives.
setItems((prev) => [...prev, {
id: `local-${Date.now()}`,
role: 'user',
content: trimmed,
timestamp: Date.now(),
}])
setIsProcessing(true)
try {
if (session.mode === 'direct') {
const res = await window.ipc.invoke('codeSession:sendMessage', { sessionId: session.id, text: trimmed })
if (!res.accepted) {
setIsProcessing(false)
return { ok: false, error: res.error ?? 'The session is busy.' }
}
} else {
await window.ipc.invoke('runs:createMessage', {
runId: session.id,
message: trimmed,
codeMode: session.agent,
codeCwd: session.cwd,
codePolicy: session.policy,
})
}
return { ok: true }
} catch (err) {
setIsProcessing(false)
return { ok: false, error: err instanceof Error ? err.message : 'Failed to send message' }
}
}, [session])
const stop = useCallback(async () => {
if (!sessionId) return
await window.ipc.invoke('codeSession:stop', { sessionId })
}, [sessionId])
const resolvePermission = useCallback(async (decision: PermissionDecision) => {
if (!pendingPermission) return
setPendingPermission(null)
await window.ipc.invoke('codeRun:resolvePermission', {
requestId: pendingPermission.requestId,
decision,
})
}, [pendingPermission])
// Rowboat-mode copilot gates — same IPC the main chat uses.
const respondToToolPermission = useCallback(async (
toolCallId: string,
subflow: string[],
response: 'approve' | 'deny',
scope?: 'once' | 'session' | 'always',
) => {
if (!sessionId) return
setPendingToolPermissions((prev) => {
const next = new Map(prev)
next.delete(toolCallId)
return next
})
await window.ipc.invoke('runs:authorizePermission', {
runId: sessionId,
authorization: { subflow, toolCallId, response, scope },
})
}, [sessionId])
const respondToAskHuman = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
if (!sessionId) return
setPendingAskHumans((prev) => {
const next = new Map(prev)
next.delete(toolCallId)
return next
})
await window.ipc.invoke('runs:provideHumanInput', {
runId: sessionId,
reply: { subflow, toolCallId, response },
})
}, [sessionId])
return {
items,
liveText,
isProcessing,
pendingPermission,
pendingToolPermissions,
pendingAskHumans,
loading,
send,
stop,
resolvePermission,
respondToToolPermission,
respondToAskHuman,
}
}

View file

@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react'
import type { CodeProject, CodeSession, CodeSessionStatus, GitRepoInfo } from '@x/shared/src/code-sessions.js'
export interface ProjectRow {
project: CodeProject
git: GitRepoInfo
}
const STATUS_RANK: Record<CodeSessionStatus, number> = {
'needs-you': 0,
working: 1,
idle: 2,
}
// Projects + sessions + live statuses for the Code section. Statuses stream
// over `codeSession:status` (pushed by the main-process tracker); the lists
// load on demand and on session lifecycle changes.
export function useCodeSessions() {
const [projects, setProjects] = useState<ProjectRow[]>([])
const [sessions, setSessions] = useState<CodeSession[]>([])
const [statuses, setStatuses] = useState<Record<string, CodeSessionStatus>>({})
const [loaded, setLoaded] = useState(false)
const refresh = useCallback(async () => {
try {
const [projectsRes, sessionsRes] = await Promise.all([
window.ipc.invoke('codeProject:list', null),
window.ipc.invoke('codeSession:list', null),
])
setProjects(projectsRes.projects)
setSessions(sessionsRes.sessions)
setStatuses((prev) => ({ ...sessionsRes.statuses, ...prev }))
} finally {
setLoaded(true)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
return window.ipc.on('codeSession:status', ({ sessionId, status }) => {
setStatuses((prev) => (prev[sessionId] === status ? prev : { ...prev, [sessionId]: status }))
// Turn boundaries bump lastActivityAt — refresh ordering when one ends.
if (status === 'idle') {
void window.ipc.invoke('codeSession:list', null).then((res) => setSessions(res.sessions))
}
})
}, [])
const statusOf = useCallback(
(sessionId: string): CodeSessionStatus => statuses[sessionId] ?? 'idle',
[statuses],
)
const sortedSessions = [...sessions].sort((a, b) => {
const rank = STATUS_RANK[statusOf(a.id)] - STATUS_RANK[statusOf(b.id)]
if (rank !== 0) return rank
return (b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt)
})
return {
projects,
sessions: sortedSessions,
statuses,
statusOf,
loaded,
refresh,
setSessions,
}
}

View file

@ -0,0 +1,254 @@
import { useCallback, useEffect, useState } from 'react'
import {
FileDiff,
FilePlus2,
FileX2,
FileEdit,
GitBranch,
GitMerge,
MoreHorizontal,
RefreshCw,
Trash2,
} from 'lucide-react'
import type { CodeSession, CodeSessionStatus, GitStatusFile } from '@x/shared/src/code-sessions.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { CodeFileTree } from './file-tree'
import { CodeFileViewer } from './file-viewer'
import { DiffViewer } from './diff-viewer'
type GitStatus = {
isRepo: boolean
branch: string | null
hasCommits: boolean
files: GitStatusFile[]
}
const STATE_ICON: Record<GitStatusFile['state'], typeof FileEdit> = {
modified: FileEdit,
added: FilePlus2,
untracked: FilePlus2,
deleted: FileX2,
renamed: FileEdit,
}
// Right pane of a coding session: a diff reviewer first (Changes), a code
// browser second (Files). Read-only in v1 by design.
export function WorkspacePane({
session,
status,
openDiffPath,
onDiffOpened,
onSessionChanged,
}: {
session: CodeSession
status: CodeSessionStatus
// A file path requested from the chat (clicking a changed file in a tool call).
openDiffPath: string | null
onDiffOpened: () => void
onSessionChanged: () => void
}) {
const [tab, setTab] = useState<'changes' | 'files'>('changes')
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null)
const [diffPath, setDiffPath] = useState<string | null>(null)
const [filePath, setFilePath] = useState<string | null>(null)
const [merging, setMerging] = useState(false)
const refreshStatus = useCallback(async () => {
try {
const res = await window.ipc.invoke('codeSession:gitStatus', { sessionId: session.id })
setGitStatus(res)
} catch {
setGitStatus(null)
}
}, [session.id])
useEffect(() => {
setTab('changes')
setDiffPath(null)
setFilePath(null)
void refreshStatus()
}, [refreshStatus])
// Refresh on turn end, and poll lightly while the agent is working — the
// session cwd lives outside the workspace watcher, so there are no change
// events to react to.
useEffect(() => {
if (status === 'idle') {
void refreshStatus()
return
}
const interval = setInterval(() => void refreshStatus(), 5000)
return () => clearInterval(interval)
}, [status, refreshStatus])
// Chat asked to show a specific file's diff.
useEffect(() => {
if (!openDiffPath) return
// Tool events may carry absolute paths — make them cwd-relative.
const rel = openDiffPath.startsWith(session.cwd + '/')
? openDiffPath.slice(session.cwd.length + 1)
: openDiffPath
setTab('changes')
setDiffPath(rel)
onDiffOpened()
}, [openDiffPath, session.cwd, onDiffOpened])
const handleMergeBack = async () => {
setMerging(true)
try {
const res = await window.ipc.invoke('codeSession:mergeBack', { sessionId: session.id })
if (res.ok) {
toast.success(res.message)
onSessionChanged()
} else {
toast.error(res.message, { duration: 10000 })
}
} finally {
setMerging(false)
}
}
const handleCleanup = async (deleteBranch: boolean) => {
const res = await window.ipc.invoke('codeSession:cleanupWorktree', { sessionId: session.id, deleteBranch })
if (res.success) {
toast.success('Worktree removed. The session now works directly in the repo.')
onSessionChanged()
} else {
toast.error(res.error ?? 'Failed to remove worktree')
}
}
const dirtyCount = gitStatus?.files.length ?? 0
const worktreeActive = session.worktree && !session.worktree.removedAt
return (
<div className="flex h-full min-h-0 flex-col">
{/* Header: branch + worktree controls */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs text-muted-foreground">
{gitStatus?.isRepo ? (
<>
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate font-mono">{gitStatus.branch ?? '(no branch)'}</span>
{dirtyCount > 0 && (
<span className="shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
{dirtyCount} changed
</span>
)}
</>
) : (
<span>Not a git repository</span>
)}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => void refreshStatus()} title="Refresh">
<RefreshCw className="size-3.5" />
</Button>
{worktreeActive && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2 text-xs">
<GitMerge className="size-3.5" />
Worktree
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={merging} onClick={() => void handleMergeBack()}>
<GitMerge className="size-4" />
Merge back into repo
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleCleanup(false)}>
<Trash2 className="size-4" />
Remove worktree (keep branch)
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => void handleCleanup(true)}>
<Trash2 className="size-4" />
Remove worktree and branch
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Tabs */}
<div className="flex items-center gap-1 border-b px-3 py-1.5">
{(['changes', 'files'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
'rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors',
tab === t ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
)}
>
{t === 'changes' ? `Changes${dirtyCount > 0 ? ` (${dirtyCount})` : ''}` : 'Files'}
</button>
))}
</div>
{/* Body */}
<div className="min-h-0 flex-1">
{tab === 'changes' && (
diffPath ? (
<DiffViewer sessionId={session.id} path={diffPath} onClose={() => setDiffPath(null)} />
) : (
<div className="h-full overflow-auto p-2">
{!gitStatus?.isRepo && (
<p className="p-3 text-sm text-muted-foreground">
This folder isn't a git repository, so there's nothing to diff. The Files tab still works.
</p>
)}
{gitStatus?.isRepo && gitStatus.files.length === 0 && (
<p className="p-3 text-sm text-muted-foreground">No uncommitted changes.</p>
)}
{gitStatus?.files.map((file) => {
const Icon = STATE_ICON[file.state]
return (
<button
key={file.path}
type="button"
onClick={() => setDiffPath(file.path)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-muted"
title={file.path}
>
<Icon className={cn(
'size-3.5 shrink-0',
file.state === 'deleted' ? 'text-red-500' : file.state === 'modified' || file.state === 'renamed' ? 'text-amber-500' : 'text-green-600',
)} />
<span className="min-w-0 flex-1 truncate font-mono">{file.path}</span>
{file.insertions !== null && <span className="shrink-0 text-green-600">+{file.insertions}</span>}
{file.deletions !== null && <span className="shrink-0 text-red-500">{file.deletions}</span>}
</button>
)
})}
</div>
)
)}
{tab === 'files' && (
filePath ? (
<CodeFileViewer sessionId={session.id} path={filePath} onClose={() => setFilePath(null)} />
) : (
<div className="h-full overflow-auto">
<CodeFileTree sessionId={session.id} selectedPath={filePath} onSelectFile={setFilePath} />
</div>
)
)}
</div>
{tab === 'changes' && !diffPath && dirtyCount > 0 && (
<div className="border-t px-3 py-1.5 text-[11px] text-muted-foreground">
<FileDiff className="mr-1 inline size-3" />
Click a file to review its diff.
</div>
)}
</div>
)
}

View file

@ -28,7 +28,7 @@ type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?:
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
type Row = TextRow | ToolRow | PlanRow | PermRow
function reduceEvents(events: CodeRunEvent[]): Row[] {
export function reduceEvents(events: CodeRunEvent[]): Row[] {
const rows: Row[] = []
const toolIdx = new Map<string, number>()
let planIdx = -1
@ -107,7 +107,14 @@ function planMarker(status?: string) {
const basename = (p: string) => p.split(/[\\/]/).pop() || p
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
export function CodingRunTimeline({
events,
onOpenDiff,
}: {
events: CodeRunEvent[]
// When set, changed-file names become clickable (the Code section opens the diff).
onOpenDiff?: (path: string) => void
}) {
const rows = useMemo(() => reduceEvents(events), [events])
if (rows.length === 0) {
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent</div>
@ -117,7 +124,7 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
{rows.map((row) => {
if (row.kind === 'text') {
return (
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
<p key={row.id} className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/90">
{row.text}
</p>
)
@ -136,9 +143,21 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
{row.diffs.length > 0 && (
<div className="ml-7 flex flex-col gap-0.5">
{row.diffs.map((d) => (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
onOpenDiff ? (
<button
key={d}
type="button"
onClick={() => onOpenDiff(d)}
className="truncate text-left font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
title={d}
>
{basename(d)}
</button>
) : (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
)
))}
</div>
)}

View file

@ -300,7 +300,10 @@ function buildEmailDocument(
opts: { theme: 'light' | 'dark'; adaptToTheme: boolean },
): string {
const useDark = opts.theme === 'dark' && opts.adaptToTheme
const colorScheme = opts.theme === 'dark' ? 'light dark' : 'light'
// Only opt into the dark color scheme when the email actually adapts to the
// theme — otherwise Chromium paints the canvas dark under emails that
// assume a white background.
const colorScheme = useDark ? 'light dark' : 'light'
const bodyColor = useDark ? '#d4d4d8' : '#202124'
const linkColor = useDark ? '#a78bfa' : '#1a73e8'
const quoteBorder = useDark ? '#2e2e35' : '#dadce0'

View file

@ -1759,52 +1759,122 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
// Engine provisioning runs in the main process and keeps going even if the Settings
// dialog is closed. Track its state at MODULE level (not in the row component, which
// unmounts on close) so reopening Settings still shows the live % instead of the Enable
// button. A single persistent listener on the progress channel feeds this store.
type ProvState = { pct: number | null; error?: string }
const provStore: Record<string, ProvState | undefined> = {}
// Agents we provisioned this session — used to show "Ready" immediately on success
// without waiting for the async status refresh to round-trip (which caused the row to
// briefly flash the Enable button again).
const enabledOptimistic = new Set<string>()
const provListeners = new Set<() => void>()
let provChannelHooked = false
function notifyProv() { provListeners.forEach((l) => l()) }
function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): void {
if (provStore[agent] && !provStore[agent]!.error) return // already in flight
provStore[agent] = { pct: null }
notifyProv()
if (!provChannelHooked) {
provChannelHooked = true
window.ipc.on('codeMode:engineProgress', (p) => {
const cur = provStore[p.agent]
if (!cur) return
const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct
provStore[p.agent] = { pct }
notifyProv()
})
}
window.ipc.invoke('codeMode:provisionEngine', { agent })
.then((res) => {
if (res.success) {
// Mark installed optimistically so the row shows "Ready" the instant the flag
// clears — don't depend on the async status refresh (which re-renders the parent
// separately and left a window showing the Enable button). loadStatus still runs
// in the background to sync the real status.
enabledOptimistic.add(agent)
provStore[agent] = undefined
void onDone()
} else {
provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' }
}
})
.catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } })
.finally(notifyProv)
}
function useProvisioning(agent: string): ProvState | undefined {
const [, force] = useState(0)
useEffect(() => {
const l = () => force((n) => n + 1)
provListeners.add(l)
return () => { provListeners.delete(l) }
}, [])
return provStore[agent]
}
function AgentStatusRow({
name,
installLink,
agent,
signInCommand,
status,
onProvisioned,
}: {
name: string
installLink: string
agent: 'claude' | 'codex'
signInCommand: string
status: AgentStatus | null
onProvisioned: () => void
}) {
const ready = status?.installed && status?.signedIn
const needsSignInOnly = status?.installed && !status?.signedIn
const prov = useProvisioning(agent)
const provisioning = prov !== undefined && prov.error === undefined
const error = prov?.error ?? null
const enable = useCallback(() => startProvisioning(agent, onProvisioned), [agent, onProvisioned])
// Treat a just-enabled engine as installed even before the status refresh lands.
const installed = (status?.installed ?? false) || enabledOptimistic.has(agent)
const ready = installed && status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Installed
<span className={cn("inline-flex items-center gap-1", installed ? "text-green-600" : "text-muted-foreground")}>
{installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
{installed ? 'Engine ready' : 'Not enabled'}
</span>
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Signed in
</span>
</div>
{error && <div className="text-xs text-red-600 mt-1 break-words">{error}</div>}
</div>
{ready ? (
{provisioning ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground shrink-0 tabular-nums">
<Loader2 className="size-3 animate-spin" />
{prov?.pct != null ? `${prov.pct}%` : null}
</span>
) : ready ? (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
Ready
</span>
) : needsSignInOnly ? (
) : !installed ? (
<button
type="button"
onClick={enable}
className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 shrink-0"
>
Enable
</button>
) : (
<span className="text-xs text-muted-foreground shrink-0">
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
</span>
) : (
<a
href={installLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
Install &amp; sign in
</a>
)}
</div>
)
@ -1907,6 +1977,14 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
</p>
<p>
For each agent you want to use, you must have it{' '}
<strong className="text-foreground">installed and logged in</strong> on this machine: click{' '}
<strong className="text-foreground">Enable</strong> below to download its engine, and sign in by
running <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">claude login</code>{' '}
or <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">codex login</code>{' '}
in your terminal. Code mode uses that saved login.
</p>
</div>
<div className="space-y-2">
@ -1924,15 +2002,17 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="space-y-2">
<AgentStatusRow
name="Claude Code"
installLink="https://claude.ai/code"
agent="claude"
signInCommand="claude login"
status={status?.claude ?? null}
onProvisioned={loadStatus}
/>
<AgentStatusRow
name="Codex"
installLink="https://developers.openai.com/codex/cli"
agent="codex"
signInCommand="codex login"
status={status?.codex ?? null}
onProvisioned={loadStatus}
/>
</div>
</div>
@ -1984,8 +2064,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-amber-900 dark:text-amber-200">
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
account, then click Re-check.
Neither Claude Code nor Codex is ready. Click Enable above to download an engine, sign in with a
subscription account, then click Re-check.
</div>
</div>
)}

View file

@ -3,8 +3,10 @@
import * as React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
ArrowUpRight,
Bot,
ChevronRight,
Code2,
FileText,
FilePlus,
Folder,
@ -89,13 +91,6 @@ type KnowledgeActions = {
onOpenInNewTab?: (path: string) => void
}
function displayNoteName(node: TreeNode): string {
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
return node.name.slice(0, -3)
}
return node.name
}
function formatBillingPlanName(plan: string | null | undefined) {
if (!plan) return 'No plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan`
@ -168,17 +163,19 @@ type SidebarContentPanelProps = {
knowledgeActions: KnowledgeActions
bgTaskSummaries?: TaskSummary[]
onOpenMeetings?: () => void
onOpenCode?: () => void
onOpenBgTasks?: () => void
onOpenAgent?: (slug: string) => void
recentRuns?: { id: string; title?: string; createdAt: string }[]
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
onOpenRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenEmail?: (threadId?: string) => void
onOpenHome?: () => void
onNewChat?: () => void
onToggleBrowser?: () => void
onVoiceNoteCreated?: (path: string) => void
/** Which primary destination is currently active, for nav highlighting. */
activeNav?: 'home' | 'email' | 'meetings' | 'knowledge' | 'agents' | 'workspaces' | null
activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'workspaces' | null
/** Live meeting recording state, so the recording row can show its indicator/stop. */
meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping'
recordingMeetingSource?: string | null
@ -412,14 +409,14 @@ function SyncStatusBar() {
export function SidebarContentPanel({
tree,
onSelectFile,
knowledgeActions,
bgTaskSummaries = [],
onOpenMeetings,
onOpenCode,
onOpenBgTasks,
onOpenAgent,
recentRuns = [],
onOpenRun,
onOpenChatHistory,
onOpenEmail,
onOpenHome,
onNewChat,
@ -445,7 +442,22 @@ export function SidebarContentPanel({
const [unreadEmailCount, setUnreadEmailCount] = useState(0)
const [emailThreads, setEmailThreads] = useState<SidebarEmailThread[]>([])
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([])
const [quickAccessExpanded, setQuickAccessExpanded] = useState(true)
const [chatsExpanded, setChatsExpanded] = useState(true)
// The Code section only makes sense with a coding agent available — same
// flag the chat composer's code chip uses (auto-on when Claude Code or
// Codex is installed + signed in; explicit toggle in settings wins).
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
useEffect(() => {
const load = () => {
window.ipc.invoke('codeMode:getConfig', null)
.then((r) => setCodeModeEnabled(r.enabled))
.catch(() => setCodeModeEnabled(false))
}
load()
window.addEventListener('code-mode-config-changed', load)
return () => window.removeEventListener('code-mode-config-changed', load)
}, [])
useEffect(() => {
let cancelled = false
@ -524,59 +536,16 @@ export function SidebarContentPanel({
.slice(0, 10)
}, [tree])
// Recents: most recently touched notes / agents / chats, interleaved by
// recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall.
type QuickAccessItem = {
key: string
label: string
recency: number
type: 'note' | 'agent' | 'chat'
onClick: () => void
}
const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => {
const items: QuickAccessItem[] = []
for (const note of recentNotes.slice(0, 4)) {
items.push({
key: `note:${note.path}`,
label: displayNoteName(note),
recency: note.stat?.mtimeMs ?? 0,
type: 'note',
onClick: () => onSelectFile(note.path, 'file'),
})
}
const agentRecency = (t: TaskSummary) => {
const ts = t.lastRunAt ?? t.lastAttemptAt ?? t.createdAt
const ms = ts ? new Date(ts).getTime() : 0
// Chats: the 5 most recently modified chats, newest first.
const recentChats = React.useMemo(() => {
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) {
items.push({
key: `agent:${t.slug}`,
label: t.name,
recency: agentRecency(t),
type: 'agent',
onClick: () => onOpenAgent?.(t.slug),
})
}
const chatRecency = (r: { createdAt: string }) => {
const ms = new Date(r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) {
items.push({
key: `chat:${r.id}`,
label: r.title || '(Untitled chat)',
recency: chatRecency(r),
type: 'chat',
onClick: () => onOpenRun?.(r.id),
})
}
return items.sort((a, b) => b.recency - a.recency).slice(0, 12)
}, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun])
return [...recentRuns]
.sort((a, b) => chatRecency(b) - chatRecency(a))
.slice(0, 10)
}, [recentRuns])
// Workspace count for the Workspaces sublabel — top-level dir children of
// knowledge/Workspace (matches WorkspaceView's root listing).
@ -834,6 +803,14 @@ export function SidebarContentPanel({
</div>
) : null}
</SidebarMenuItem>
{codeModeEnabled && (
<SidebarMenuItem>
<SidebarMenuButton isActive={activeNav === 'code'} onClick={onOpenCode}>
<Code2 className="size-4 shrink-0" />
<span className="flex-1 truncate">Code</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
isActive={activeNav === 'knowledge'}
@ -895,38 +872,43 @@ export function SidebarContentPanel({
<div className="mx-3 border-t border-sidebar-border" />
{/* Recents */}
{/* Chats */}
<SidebarGroup className="flex flex-col">
<SidebarGroupContent>
<button
type="button"
onClick={() => setQuickAccessExpanded((v) => !v)}
onClick={() => setChatsExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
>
<ChevronRight className={cn('size-3 transition-transform', quickAccessExpanded && 'rotate-90')} />
<span className="flex-1 text-left">Recents</span>
<ChevronRight className={cn('size-3 transition-transform', chatsExpanded && 'rotate-90')} />
<span className="flex-1 text-left">Chats</span>
</button>
{quickAccessExpanded && (
quickAccessItems.length === 0 ? (
{chatsExpanded && (
recentChats.length === 0 ? (
<div className="px-4 pb-2 text-[11.5px] italic text-muted-foreground">
Recent notes and agents show up here.
Your recent chats show up here.
</div>
) : (
<SidebarMenu>
{quickAccessItems.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton onClick={item.onClick}>
{item.type === 'agent' ? (
<Bot className="size-4 shrink-0 text-muted-foreground" />
) : item.type === 'chat' ? (
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate">{item.label}</span>
{recentChats.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{onOpenChatHistory && (
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => onOpenChatHistory()}
className="text-muted-foreground"
>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">View all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
)
)}

View file

@ -0,0 +1,111 @@
// Build-time generator for the code-mode engine manifest.
//
// Code mode provisions each agent's native engine on demand by downloading the
// per-platform npm package AT THE EXACT VERSION OUR ACP ADAPTER DEPENDS ON, so the
// adapter <-> engine handshake is always in lockstep. This script reads those pinned
// versions from the installed adapter package.json files, queries the npm registry for
// each platform package's tarball URL + integrity, and writes them to
// `src/code-mode/acp/engine-manifest.ts` (committed; regenerate on a version bump).
//
// Usage: node scripts/gen-engine-manifest.mjs (run from packages/core)
//
// Why a committed .ts (not a fetched-at-build .json): it needs no network at app build
// time, works offline in dev, is reviewable in PRs, and esbuild inlines it into the
// packaged main bundle.
import { createRequire } from 'module';
import { writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import * as path from 'path';
const require = createRequire(import.meta.url);
const REGISTRY = 'https://registry.npmjs.org';
// Platform keys we publish for each agent. These mirror the optionalDependencies of the
// engine packages. claude ships musl variants; codex does not.
const CLAUDE_PLATFORMS = [
'darwin-arm64', 'darwin-x64',
'linux-x64', 'linux-arm64', 'linux-x64-musl', 'linux-arm64-musl',
'win32-x64', 'win32-arm64',
];
const CODEX_PLATFORMS = [
'darwin-arm64', 'darwin-x64',
'linux-x64', 'linux-arm64',
'win32-x64', 'win32-arm64',
];
// Read a pinned dependency version from an adapter's package.json, stripping any range
// prefix (^, ~). createRequire resolves the adapter from the installed node_modules.
function pinnedDep(adapterPkg, depName) {
const pj = require(`${adapterPkg}/package.json`);
const spec = (pj.dependencies || {})[depName] || (pj.optionalDependencies || {})[depName];
if (!spec) throw new Error(`${adapterPkg} has no dependency on ${depName}`);
return spec.replace(/^[\^~]/, '');
}
// Fetch a single version's manifest from the registry and return its dist coordinates.
async function distFor(pkg, version) {
const url = `${REGISTRY}/${pkg}/${version}`;
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) return null; // platform not published for this version
throw new Error(`registry ${res.status} for ${pkg}@${version}`);
}
const doc = await res.json();
return { tarball: doc.dist.tarball, integrity: doc.dist.integrity };
}
// Build one agent's manifest entry: { version, platforms: { <key>: {tarball, integrity} } }.
// pkgFor(platform) -> { pkg, version } gives the registry coordinates for each platform.
async function buildAgent(version, platforms, pkgFor) {
const out = { version, platforms: {} };
for (const key of platforms) {
const { pkg, version: pv } = pkgFor(key);
const dist = await distFor(pkg, pv);
if (!dist) {
console.warn(` ! ${pkg}@${pv} not found on registry — skipping ${key}`);
continue;
}
out.platforms[key] = { pkg, pkgVersion: pv, ...dist };
console.log(`${key}: ${pkg}@${pv}`);
}
return out;
}
async function main() {
// Pinned engine versions, read straight from the adapters we ship.
const claudeVersion = pinnedDep('@agentclientprotocol/claude-agent-acp', '@anthropic-ai/claude-agent-sdk');
const codexVersion = pinnedDep('@agentclientprotocol/codex-acp', '@openai/codex');
console.log(`claude engine: @anthropic-ai/claude-agent-sdk@${claudeVersion}`);
console.log(`codex engine: @openai/codex@${codexVersion}`);
const manifest = {
claude: await buildAgent(claudeVersion, CLAUDE_PLATFORMS, (key) => ({
pkg: `@anthropic-ai/claude-agent-sdk-${key}`,
version: claudeVersion,
})),
codex: await buildAgent(codexVersion, CODEX_PLATFORMS, (key) => ({
// codex publishes platform binaries as VERSIONS of @openai/codex
// (e.g. 0.128.0-darwin-arm64), not as separate package names.
pkg: '@openai/codex',
version: `${codexVersion}-${key}`,
})),
};
const outPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..', 'src', 'code-mode', 'acp', 'engine-manifest.ts',
);
const banner =
'// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.\n' +
'// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.\n' +
'// Maps each agent + platform to the npm tarball + integrity of its native engine.\n\n';
const body = `export const ENGINE_MANIFEST = ${JSON.stringify(manifest, null, 4)} as const;\n`;
writeFileSync(outPath, banner + body);
console.log(`\nWrote ${outPath}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -1205,6 +1205,8 @@ export async function* streamAgent({
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
let codeMode: 'claude' | 'codex' | null = null;
let codeCwd: string | null = null;
let codePolicy: 'ask' | 'auto-approve-reads' | 'yolo' | null = null;
let middlePaneContext:
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string }
@ -1304,6 +1306,8 @@ export async function* streamAgent({
abortRegistry,
publish: (event) => bus.publish(event),
codeMode,
codeCwd,
codePolicy,
});
}
} catch (error) {
@ -1363,6 +1367,8 @@ export async function* streamAgent({
// Code mode is per-message: latest message decides whether the assistant
// should route coding work through the code-with-agents skill / chosen agent.
codeMode = msg.codeMode ?? null;
codeCwd = msg.codeCwd ?? null;
codePolicy = msg.codePolicy ?? null;
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
@ -1460,7 +1466,7 @@ The chip is the single source of truth for which agent runs:
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once).
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once)`}.
- \`prompt\`: a clear, self-contained coding instruction.
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.

View file

@ -1,6 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync' | 'code_session';
export interface UseCaseContext {
useCase: UseCase;

View file

@ -829,16 +829,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// chip set) — otherwise it can anchor on the thread's earlier agent and ignore a
// chip change. Honor the chip so switching it deterministically switches agents.
const effectiveAgent = ctx.codeMode ?? agent;
// Code-section sessions pin the working directory — never trust the model's
// cwd argument over the session's.
const effectiveCwd = ctx.codeCwd ?? cwd;
const manager = container.resolve<CodeModeManager>('codeModeManager');
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
// Approval policy from settings; default to asking the user.
// Approval policy: the session's (Code section) wins, else global settings,
// else default to asking the user.
let policy: ApprovalPolicy = 'ask';
try {
const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig();
if (cfg.approvalPolicy) policy = cfg.approvalPolicy;
} catch {
// fall back to 'ask'
if (ctx.codePolicy) {
policy = ctx.codePolicy;
} else {
try {
const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig();
if (cfg.approvalPolicy) policy = cfg.approvalPolicy;
} catch {
// fall back to 'ask'
}
}
// On stop, unblock any pending approval card so the broker stops waiting for
@ -855,7 +863,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const result = await manager.runPrompt({
runId: ctx.runId,
agent: effectiveAgent,
cwd,
cwd: effectiveCwd,
prompt,
policy,
signal: ctx.signal,

View file

@ -18,6 +18,11 @@ export interface ToolContext {
// it is the authoritative coding agent — code_agent_run uses it rather than the
// agent the model guessed, so switching the chip deterministically switches agents.
codeMode?: 'claude' | 'codex' | null;
// Set for Code-section sessions in Rowboat mode: the session's working directory
// and approval policy. code_agent_run honors these over the model's cwd argument
// and the global approval policy.
codeCwd?: string | null;
codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo' | null;
}
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {

View file

@ -9,6 +9,7 @@ export type MiddlePaneContext =
| { kind: 'browser'; url: string; title: string };
export type CodeMode = 'claude' | 'codex';
export type CodePolicy = 'ask' | 'auto-approve-reads' | 'yolo';
type EnqueuedMessage = {
messageId: string;
@ -17,11 +18,16 @@ type EnqueuedMessage = {
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
codeMode?: CodeMode;
// Code-section sessions pin the coding agent's working directory and
// approval policy for the turn (code_agent_run honors these over its
// model-provided arguments / the global policy).
codeCwd?: string;
codePolicy?: CodePolicy;
middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -37,7 +43,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
@ -49,6 +55,8 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
codePolicy,
middlePaneContext,
});
return id;

View file

@ -1,7 +1,9 @@
import { createRequire } from 'module';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { CodingAgent } from './types.js';
import { resolveClaudeExecutable } from './claude-exec.js';
import { getProvisionedEnginePath } from './engine-provisioner.js';
import { loginShellPath } from './shell-env.js';
const require = createRequire(import.meta.url);
@ -20,13 +22,36 @@ export interface AgentLaunchSpec {
env: NodeJS.ProcessEnv;
}
// Locate an adapter's package.json. In packaged builds Electron Forge strips the
// workspace node_modules, so the adapters (+ their dependency closure) are staged
// next to the bundle at `.package/acp/node_modules` by the generateAssets hook (see
// apps/main/forge.config.cjs). In dev they resolve normally via the pnpm symlink.
// Try the staged location first, then fall back to ordinary resolution.
function resolveAdapterPkgJson(pkg: string): string {
// The main process is esbuild-bundled to `.package/dist/main.cjs`, so the staged
// adapters live one level up at `.package/acp`. (import.meta.url is rewritten to
// the bundle path by bundle.mjs, so this holds in both dev and packaged builds.)
const stagedRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'acp');
for (const opts of [{ paths: [stagedRoot] }, undefined]) {
try {
return require.resolve(`${pkg}/package.json`, opts);
} catch {
// not here — try the next resolution strategy
}
}
throw new Error(
`ACP adapter '${pkg}' not found — expected it staged at ` +
`${path.join(stagedRoot, 'node_modules', pkg)} (packaged build) or resolvable ` +
`from node_modules (dev).`,
);
}
// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an
// absolute path so we can spawn it directly with `node <entry>`. createRequire lets
// us resolve workspace/pnpm-installed packages from this module's location.
// absolute path so we can spawn it directly with `node <entry>`.
function resolveAdapterEntry(pkg: string): string {
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
const pkgJsonPath = resolveAdapterPkgJson(pkg);
const pkgDir = path.dirname(pkgJsonPath);
const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> };
const pkgJson = require(pkgJsonPath) as { bin?: string | Record<string, string> };
const bin = pkgJson.bin;
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
if (!rel) {
@ -39,14 +64,31 @@ export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec {
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
const env: NodeJS.ProcessEnv = { ...process.env };
// Point the Claude adapter at the real claude executable. On Windows this is
// mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a
// PATH safety net for GUI launches. Resolver is a no-op when claude isn't found,
// leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire
// an equivalent when we add Codex support.)
if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) {
const exe = resolveClaudeExecutable();
if (exe) env.CLAUDE_CODE_EXECUTABLE = exe;
// Graft the user's login-shell PATH onto the engine's env. GUI (Finder) launches
// inherit launchd's stripped PATH, so tools the engine spawns — git, gh, rg, bash —
// would otherwise fail with "command not found" even though they work from a
// terminal. No-op on Windows / when the probe fails.
const shellPath = loginShellPath();
if (shellPath && shellPath !== env.PATH) {
const dirs = [...shellPath.split(path.delimiter), ...(env.PATH ?? '').split(path.delimiter)];
env.PATH = [...new Set(dirs.filter(Boolean))].join(path.delimiter);
}
// Point the adapter at the engine the user already enabled in Settings. We do NOT
// download here — getProvisionedEnginePath throws a clear "enable it in Settings"
// error if the engine isn't present, so code mode never triggers a surprise
// mid-chat download. The version is locked to what the adapter was built against, so
// the ACP handshake is always compatible. The adapters honor these env vars
// (claude: CLAUDE_CODE_EXECUTABLE, codex: CODEX_PATH).
const executablePath = getProvisionedEnginePath(agent);
if (agent === 'claude') {
env.CLAUDE_CODE_EXECUTABLE = executablePath;
// Make the claude-agent-sdk log the exact spawn command + claude's stderr to
// ~/.claude/debug/sdk-*.txt, so a failed/hung launch has a diagnosable trail
// instead of a silently dropped connection.
env.DEBUG_CLAUDE_AGENT_SDK = '1';
} else {
env.CODEX_PATH = executablePath;
}
// We spawn the adapter with process.execPath. Inside Electron's main process

View file

@ -27,6 +27,16 @@ export interface AcpClientOptions {
onEvent: (event: CodeRunEvent) => void;
}
// Deadline for the startup phases (initialize / session create+load). A healthy cold
// start — adapter boot, engine spawn, SDK handshake, MCP connects — takes seconds; only
// a wedged engine takes this long. Without a deadline that failure mode is an infinite
// "(pending...)" with zero feedback. Prompts are intentionally NOT time-limited: turns
// legitimately run for many minutes and may wait on user permission asks. Overridable
// via ROWBOAT_ACP_STARTUP_TIMEOUT_MS (CI smoke test; escape hatch for MCP-heavy setups).
const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) > 0
? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS)
: 60_000;
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
function toEvent(update: SessionUpdate): CodeRunEvent {
switch (update.sessionUpdate) {
@ -130,19 +140,40 @@ export class AcpClient {
this.connection = new ClientSideConnection(() => client, stream);
try {
const init = await this.connection.initialize({
const init = await this.withStartupTimeout(this.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
}));
this.loadSession_ = init.agentCapabilities?.loadSession === true;
} catch (e) {
throw this.enrich(e, 'initialize');
}
}
// Race a startup-phase request against the deadline so a wedged engine fails with a
// clear, enriched error instead of leaving the turn pending forever. Callers dispose
// the client on failure, which kills the spawned adapter.
private async withStartupTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(
`timed out after ${STARTUP_TIMEOUT_MS / 1000}s — the ${this.agent} engine failed to ` +
`complete startup (it may be wedged or misconfigured)`,
));
}, STARTUP_TIMEOUT_MS);
timer.unref?.();
});
try {
return await Promise.race([work, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async newSession(): Promise<string> {
try {
const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] });
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
return res.sessionId;
} catch (e) {
throw this.enrich(e, 'newSession');
@ -151,7 +182,7 @@ export class AcpClient {
async loadSession(sessionId: string): Promise<void> {
try {
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
} catch (e) {
throw this.enrich(e, 'loadSession');
}

View file

@ -0,0 +1,100 @@
// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.
// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.
// Maps each agent + platform to the npm tarball + integrity of its native engine.
export const ENGINE_MANIFEST = {
"claude": {
"version": "0.3.156",
"platforms": {
"darwin-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.156.tgz",
"integrity": "sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA=="
},
"darwin-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.156.tgz",
"integrity": "sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g=="
},
"linux-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.156.tgz",
"integrity": "sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg=="
},
"linux-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.156.tgz",
"integrity": "sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ=="
},
"linux-x64-musl": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64-musl",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.156.tgz",
"integrity": "sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA=="
},
"linux-arm64-musl": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64-musl",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.156.tgz",
"integrity": "sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw=="
},
"win32-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-win32-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.156.tgz",
"integrity": "sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw=="
},
"win32-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-win32-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.156.tgz",
"integrity": "sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw=="
}
}
},
"codex": {
"version": "0.128.0",
"platforms": {
"darwin-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-darwin-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-arm64.tgz",
"integrity": "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug=="
},
"darwin-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-darwin-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-x64.tgz",
"integrity": "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw=="
},
"linux-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-linux-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-x64.tgz",
"integrity": "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ=="
},
"linux-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-linux-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-arm64.tgz",
"integrity": "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w=="
},
"win32-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-win32-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-x64.tgz",
"integrity": "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ=="
},
"win32-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-win32-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-arm64.tgz",
"integrity": "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA=="
}
}
}
} as const;

View file

@ -0,0 +1,284 @@
// Code-mode engine provisioner.
//
// Code mode drives Claude Code / Codex through their ACP adapters, which spawn a heavy
// native engine binary (~200 MB each). We do NOT bundle those engines into the installer
// (that would add ~400 MB). Instead we provision them on demand: the first time an agent
// is used we download the per-platform npm package AT THE EXACT VERSION OUR ADAPTER WAS
// BUILT AGAINST (see engine-manifest.ts), verify its integrity, and extract it into
// ~/.rowboat/engines/<agent>/<version>/. Subsequent runs reuse the cached copy.
//
// The adapters are then pointed at the provisioned binary via CLAUDE_CODE_EXECUTABLE /
// CODEX_PATH (see agents.ts). This keeps the installer small while making code mode work
// out of the box, with no dependency on the user having a global claude/codex install.
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import { spawnSync } from 'child_process';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { ENGINE_MANIFEST } from './engine-manifest.js';
import type { CodingAgent } from './types.js';
export const ENGINES_ROOT = path.join(os.homedir(), '.rowboat', 'engines');
interface PlatformEntry {
pkg: string;
pkgVersion: string;
tarball: string;
integrity: string;
}
export interface EngineProgress {
phase: 'check' | 'download' | 'verify' | 'extract' | 'done';
/** Bytes received so far (download phase). */
receivedBytes?: number;
/** Total bytes, when the server reports content-length. */
totalBytes?: number;
}
export interface EnsureEngineOptions {
onProgress?: (p: EngineProgress) => void;
signal?: AbortSignal;
}
export interface ProvisionedEngine {
executablePath: string;
version: string;
}
// Map this process's platform/arch (+ libc on linux) to a manifest platform key for the
// given agent. Returns null when no engine is published for this platform.
function platformKey(agent: CodingAgent): string | null {
const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null;
if (!arch) return null;
const plats = ENGINE_MANIFEST[agent].platforms as Record<string, PlatformEntry>;
const candidates: string[] = [];
if (process.platform === 'darwin') {
candidates.push(`darwin-${arch}`);
} else if (process.platform === 'win32') {
candidates.push(`win32-${arch}`);
} else if (process.platform === 'linux') {
// Prefer a musl build on musl systems (Alpine); fall back to the glibc build.
if (isMuslLibc()) candidates.push(`linux-${arch}-musl`);
candidates.push(`linux-${arch}`);
}
return candidates.find((c) => c in plats) ?? null;
}
// glibc builds expose a glibcVersionRuntime in the process report header; musl (Alpine)
// does not. Same heuristic Node's native-addon loaders use.
function isMuslLibc(): boolean {
try {
const report = (process as unknown as { report?: { getReport?: () => unknown } }).report?.getReport?.();
const header = (report as { header?: Record<string, unknown> } | undefined)?.header;
return !(header && 'glibcVersionRuntime' in header);
} catch {
return false;
}
}
// Locate the engine executable inside an extracted package root. We extract the whole npm
// package (so codex's bundled ripgrep travels with it), then find the binary.
function locateExecutable(agent: CodingAgent, root: string): string | null {
if (agent === 'claude') {
for (const name of ['claude', 'claude.exe']) {
const p = path.join(root, name);
if (fs.existsSync(p)) return p;
}
return null;
}
// codex: vendor/<target-triple>/codex/codex[.exe]
const vendor = path.join(root, 'vendor');
if (!fs.existsSync(vendor)) return null;
for (const triple of fs.readdirSync(vendor)) {
for (const name of ['codex', 'codex.exe']) {
const p = path.join(vendor, triple, 'codex', name);
if (fs.existsSync(p)) return p;
}
}
return null;
}
// True when this OS/arch has a published engine for `agent` — i.e. we can provision it.
// (Used for status: code mode no longer requires a user-installed CLI.)
export function isEngineSupported(agent: CodingAgent): boolean {
return platformKey(agent) !== null;
}
// True when the pinned engine for `agent` is already downloaded and intact locally.
export function isEngineProvisioned(agent: CodingAgent): boolean {
const version = ENGINE_MANIFEST[agent].version;
const versionDir = path.join(ENGINES_ROOT, agent, version);
const metaPath = path.join(ENGINES_ROOT, agent, '.meta', `${agent}-${version}.json`);
return locateExecutable(agent, versionDir) !== null && fs.existsSync(metaPath);
}
const AGENT_LABEL: Record<CodingAgent, string> = { claude: 'Claude Code', codex: 'Codex' };
// Return the provisioned engine's executable path, or throw a clear, user-facing error.
// The chat/run path uses this — we deliberately do NOT download here: the engine must be
// enabled up front in Settings → Code Mode, so the user never eats a surprise ~200 MB
// download mid-conversation. ensureEngine() (the downloading path) is driven only by the
// Settings "Enable" action.
export function getProvisionedEnginePath(agent: CodingAgent): string {
const version = ENGINE_MANIFEST[agent].version;
const exe = locateExecutable(agent, path.join(ENGINES_ROOT, agent, version));
if (!exe) {
throw new Error(
`${AGENT_LABEL[agent]} isn't enabled yet. Open Settings → Code Mode and click Enable to download it.`,
);
}
return exe;
}
// Remove every provisioned version of `agent` except `keepVersion`, plus its stale
// .meta entries. Called after a successful install so old engines don't pile up across
// version bumps. Best-effort — never throws (cleanup must not fail a good install).
function pruneOldVersions(agent: CodingAgent, keepVersion: string): void {
const agentRoot = path.join(ENGINES_ROOT, agent);
try {
for (const name of fs.readdirSync(agentRoot)) {
// Keep the active version, the meta dir, and any in-flight temp dirs.
if (name === keepVersion || name === '.meta' || name.startsWith('.tmp-')) continue;
const full = path.join(agentRoot, name);
try {
if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true, force: true });
} catch { /* ignore a single stubborn entry */ }
}
const metaDir = path.join(agentRoot, '.meta');
if (fs.existsSync(metaDir)) {
for (const f of fs.readdirSync(metaDir)) {
if (f !== `${agent}-${keepVersion}.json`) {
try { fs.rmSync(path.join(metaDir, f), { force: true }); } catch { /* ignore */ }
}
}
}
} catch { /* agentRoot unreadable — nothing to prune */ }
}
async function downloadTo(url: string, dest: string, opts: EnsureEngineOptions): Promise<void> {
opts.onProgress?.({ phase: 'download', receivedBytes: 0 });
const res = await fetch(url, { signal: opts.signal });
if (!res.ok || !res.body) {
throw new Error(`Code mode: engine download failed (HTTP ${res.status}) — ${url}`);
}
const total = Number(res.headers.get('content-length')) || undefined;
let received = 0;
const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
body.on('data', (chunk: Buffer) => {
received += chunk.length;
opts.onProgress?.({ phase: 'download', receivedBytes: received, totalBytes: total });
});
await pipeline(body, fs.createWriteStream(dest));
}
// Verify the tarball against the npm Subresource Integrity string ("sha512-<base64>").
function verifyIntegrity(file: string, integrity: string): void {
const dash = integrity.indexOf('-');
const algo = integrity.slice(0, dash);
const expected = integrity.slice(dash + 1);
const actual = crypto.createHash(algo).update(fs.readFileSync(file)).digest('base64');
if (actual !== expected) {
throw new Error(`Code mode: engine integrity check failed (${algo}) — download may be corrupt.`);
}
}
// Extract an npm tarball, stripping its leading `package/` component so the package
// contents land directly in destDir. Uses the system tar (bsdtar on macOS/Windows 10+,
// GNU tar on Linux) — all support -xzf and --strip-components.
function extractTarball(tarPath: string, destDir: string): void {
const r = spawnSync('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1'], { stdio: 'pipe' });
if (r.status !== 0) {
const err = r.stderr?.toString().trim() || r.error?.message || `tar exited ${r.status}`;
throw new Error(`Code mode: failed to extract engine — ${err}`);
}
}
// Mark the engine (and codex's bundled ripgrep) executable on unix.
function makeExecutable(agent: CodingAgent, root: string, exe: string): void {
fs.chmodSync(exe, 0o755);
if (agent === 'codex') {
const vendor = path.join(root, 'vendor');
for (const triple of fs.existsSync(vendor) ? fs.readdirSync(vendor) : []) {
const rg = path.join(vendor, triple, 'path', 'rg');
if (fs.existsSync(rg)) fs.chmodSync(rg, 0o755);
}
}
}
/**
* Ensure the pinned engine for `agent` is provisioned locally, downloading it on first
* use. Returns the absolute path to the engine executable. Idempotent and cached.
*/
export async function ensureEngine(agent: CodingAgent, opts: EnsureEngineOptions = {}): Promise<ProvisionedEngine> {
const entry = ENGINE_MANIFEST[agent];
const version = entry.version;
const key = platformKey(agent);
if (!key) {
throw new Error(`Code mode: no ${agent} engine is available for ${process.platform}/${process.arch}.`);
}
const plat = (entry.platforms as Record<string, PlatformEntry>)[key];
const agentRoot = path.join(ENGINES_ROOT, agent);
const versionDir = path.join(agentRoot, version);
const metaDir = path.join(agentRoot, '.meta');
const metaPath = path.join(metaDir, `${agent}-${version}.json`);
opts.onProgress?.({ phase: 'check' });
// Fast path: already provisioned and intact.
const existing = locateExecutable(agent, versionDir);
if (existing && fs.existsSync(metaPath)) {
opts.onProgress?.({ phase: 'done' });
return { executablePath: existing, version };
}
// Download to a unique temp dir, verify, extract, then swap into place. Concurrent
// callers each use their own temp dir; the final rename is idempotent (same content).
fs.mkdirSync(agentRoot, { recursive: true });
const tmpRoot = fs.mkdtempSync(path.join(agentRoot, `.tmp-${version}-`));
try {
const tarPath = path.join(tmpRoot, 'engine.tgz');
await downloadTo(plat.tarball, tarPath, opts);
opts.onProgress?.({ phase: 'verify' });
verifyIntegrity(tarPath, plat.integrity);
opts.onProgress?.({ phase: 'extract' });
const extractDir = path.join(tmpRoot, 'pkg');
fs.mkdirSync(extractDir);
extractTarball(tarPath, extractDir);
const exe = locateExecutable(agent, extractDir);
if (!exe) {
throw new Error(`Code mode: ${agent} engine binary not found in the downloaded package.`);
}
if (process.platform !== 'win32') makeExecutable(agent, extractDir, exe);
// Swap the freshly extracted package into the versioned location.
if (fs.existsSync(versionDir)) fs.rmSync(versionDir, { recursive: true, force: true });
fs.renameSync(extractDir, versionDir);
const finalExe = locateExecutable(agent, versionDir);
if (!finalExe) {
throw new Error(`Code mode: ${agent} engine binary missing after install.`);
}
fs.mkdirSync(metaDir, { recursive: true });
fs.writeFileSync(metaPath, JSON.stringify({
version,
platform: key,
integrity: plat.integrity,
binRelPath: path.relative(versionDir, finalExe),
}, null, 2));
// A new version is in place — remove superseded versions so old engines
// (~200 MB each) don't accumulate after a bump. Best-effort.
pruneOldVersions(agent, version);
opts.onProgress?.({ phase: 'done' });
return { executablePath: finalExe, version };
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
}

View file

@ -15,6 +15,13 @@ export interface RunPromptArgs {
onEvent: (event: CodeRunEvent) => void;
/** Aborts the turn on stop; the manager cancels then force-kills the adapter. */
signal?: AbortSignal;
/**
* Drop the conversation replay that session/load streams on a cold resume.
* Direct sessions persist their own history (run JSONL) and render from it,
* so replaying through onEvent would duplicate every prior turn. When set,
* events only flow to onEvent once the session is open, right before prompt.
*/
suppressReplay?: boolean;
}
interface ActiveRun {
@ -51,7 +58,7 @@ export class CodeModeManager {
private readonly runs = new Map<string, ActiveRun>();
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args;
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args;
const broker = new PermissionBroker({
policy,
@ -59,7 +66,7 @@ export class CodeModeManager {
onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }),
});
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent);
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
run.inflight++;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
@ -148,6 +155,7 @@ export class CodeModeManager {
cwd: string,
broker: PermissionBroker,
onEvent: (event: CodeRunEvent) => void,
suppressReplay: boolean,
): Promise<ActiveRun> {
const existing = this.runs.get(runId);
if (existing && existing.agent === agent && existing.cwd === cwd) {
@ -157,13 +165,28 @@ export class CodeModeManager {
}
if (existing) this.dispose(runId); // agent/cwd changed — start over
const client = new AcpClient({ agent, cwd, broker, onEvent });
await client.start();
const sessionId = await this.openSession(runId, agent, cwd, client);
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
this.runs.set(runId, run);
return run;
// With suppressReplay, the client starts with a muted event sink so a
// session/load replay of the prior conversation goes nowhere; the real
// sink is installed once the session is open (below).
const client = new AcpClient({
agent,
cwd,
broker,
onEvent: suppressReplay ? () => {} : onEvent,
});
// Dispose the client if startup fails (e.g. the startup-timeout fires) so the
// spawned adapter process doesn't leak.
try {
await client.start();
const sessionId = await this.openSession(runId, agent, cwd, client);
if (suppressReplay) client.setHandlers(broker, onEvent);
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
this.runs.set(runId, run);
return run;
} catch (e) {
client.dispose();
throw e;
}
}
// Resume the persisted session for this chat when possible; else start a new one

View file

@ -0,0 +1,40 @@
import { execSync } from 'child_process';
import * as path from 'path';
let cached: string | null = null;
// The user's login-shell PATH (macOS/Linux; undefined on Windows or probe failure).
// GUI-launched Electron apps inherit launchd's stripped PATH (/usr/bin:/bin:...), so
// anything the engines spawn off process.env.PATH misses Homebrew/nvm/npm-global tools.
// The provisioned engine itself runs from an absolute path, but claude/codex spawn
// git, gh, rg, bash, etc. themselves — without this they fail with "command not found"
// on a Finder launch even though they work from a terminal.
export function loginShellPath(): string | undefined {
if (process.platform === 'win32') return undefined;
if (cached !== null) return cached || undefined;
// Prefer the user's own shell when it's POSIX-flavored, so its login profile
// (~/.zprofile for zsh — macOS default — ~/.profile for bash/sh) is the one that
// builds the PATH. fish et al. are skipped: their `echo $PATH` is space-joined.
const userShell = process.env.SHELL;
const shellOk = userShell && ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(path.basename(userShell));
const shells = [...new Set([...(shellOk ? [userShell] : []), '/bin/sh'])];
for (const shell of shells) {
try {
const out = execSync(`${shell} -lc 'echo $PATH'`, { timeout: 5000, encoding: 'utf-8' });
// Profile scripts may echo their own lines; our `echo $PATH` runs last,
// so take the last non-empty line and sanity-check it looks like a PATH.
const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.includes('/')) {
cached = last;
return last;
}
} catch {
// probe failed — try the next shell
}
}
cached = ''; // remember the failure so we don't re-pay the probe every spawn
return undefined;
}

View file

@ -0,0 +1,272 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import type { GitRepoInfo, GitStatusFile, GitFileState } from '@x/shared/dist/code-sessions.js';
const execFileAsync = promisify(execFile);
// Plain shell-outs to the system git. isomorphic-git (already in core) doesn't
// support worktrees, and these calls are simple enough that wrapping the CLI is
// both lighter and more faithful to what the user's own git would do.
const MAX_BUFFER = 32 * 1024 * 1024;
// Diff/file payloads above this are not worth shipping to the renderer.
const MAX_TEXT_BYTES = 1024 * 1024;
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: MAX_BUFFER });
return stdout;
}
let gitAvailable: Promise<boolean> | null = null;
export function isGitAvailable(): Promise<boolean> {
if (!gitAvailable) {
gitAvailable = execFileAsync('git', ['--version'], { timeout: 5000 })
.then(() => true)
.catch(() => false);
}
return gitAvailable;
}
export async function repoInfo(cwd: string): Promise<GitRepoInfo> {
const none: GitRepoInfo = { isGitRepo: false, branch: null, hasCommits: false, dirtyCount: 0 };
if (!await isGitAvailable()) return none;
try {
const inside = (await git(cwd, ['rev-parse', '--is-inside-work-tree'])).trim();
if (inside !== 'true') return none;
} catch {
return none;
}
let branch: string | null = null;
try {
branch = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || null;
} catch {
// unborn branch (no commits) — symbolic-ref still knows the name
try {
const ref = (await git(cwd, ['symbolic-ref', 'HEAD'])).trim();
branch = ref.replace(/^refs\/heads\//, '') || null;
} catch {
branch = null;
}
}
let hasCommits = false;
try {
await git(cwd, ['rev-parse', '--verify', 'HEAD']);
hasCommits = true;
} catch {
hasCommits = false;
}
let dirtyCount = 0;
try {
const out = await git(cwd, ['status', '--porcelain=v1', '-z']);
dirtyCount = out.split('\0').filter((l) => l.trim() !== '').length;
} catch {
dirtyCount = 0;
}
return { isGitRepo: true, branch, hasCommits, dirtyCount };
}
// git status/diff report paths relative to the REPO ROOT, which is not the
// session cwd when the user opened a subdirectory of a repo as their project.
// Disk reads must resolve against the root, not cwd.
async function repoToplevel(cwd: string): Promise<string> {
try {
return (await git(cwd, ['rev-parse', '--show-toplevel'])).trim() || cwd;
} catch {
return cwd;
}
}
function stateFromPorcelain(xy: string): GitFileState {
if (xy === '??') return 'untracked';
if (xy.includes('R')) return 'renamed';
if (xy.includes('A')) return 'added';
if (xy.includes('D')) return 'deleted';
return 'modified';
}
// Working-tree changes vs HEAD with insertion/deletion counts, scoped to the
// session directory's subtree (`-- .`): a project opened inside a bigger repo
// only shows its own changes. Result paths are repo-root-relative (git's
// porcelain format). Untracked files get their line count from disk (capped)
// since numstat doesn't cover them.
export async function status(cwd: string): Promise<GitStatusFile[]> {
const root = await repoToplevel(cwd);
const out = await git(cwd, ['status', '--porcelain=v1', '-z', '--', '.']);
const entries: Array<{ path: string; state: GitFileState }> = [];
// -z format: "XY path\0" and for renames "XY newPath\0oldPath\0"
const parts = out.split('\0');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part || part.length < 4) continue;
const xy = part.slice(0, 2);
const filePath = part.slice(3);
const state = stateFromPorcelain(xy);
if (state === 'renamed') i++; // skip the old path that follows
entries.push({ path: filePath, state });
}
const counts = new Map<string, { insertions: number | null; deletions: number | null }>();
try {
const numstat = await git(cwd, ['diff', 'HEAD', '--numstat', '-z', '--', '.']);
// -z numstat rows: "ins\tdel\tpath\0" (renames: "ins\tdel\0old\0new\0")
const rows = numstat.split('\0');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
if (!m) continue;
const insertions = m[1] === '-' ? null : Number(m[1]);
const deletions = m[2] === '-' ? null : Number(m[2]);
let filePath = m[3];
if (!filePath) {
// rename form: old and new paths follow as separate tokens
i += 2;
filePath = rows[i] ?? '';
}
if (filePath) counts.set(filePath, { insertions, deletions });
}
} catch {
// no HEAD yet (no commits) — leave counts empty
}
const result: GitStatusFile[] = [];
for (const entry of entries) {
let insertions: number | null = null;
let deletions: number | null = null;
const counted = counts.get(entry.path);
if (counted) {
insertions = counted.insertions;
deletions = counted.deletions;
} else if (entry.state === 'untracked') {
try {
const full = path.join(root, entry.path);
const stat = await fs.stat(full);
if (stat.isFile() && stat.size <= MAX_TEXT_BYTES) {
const content = await fs.readFile(full, 'utf8');
if (!content.includes('\0')) {
insertions = content.length === 0
? 0
: content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
deletions = 0;
}
}
} catch {
// unreadable — leave counts null
}
}
result.push({ path: entry.path, state: entry.state, insertions, deletions });
}
return result;
}
export interface FileDiff {
oldText: string;
newText: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff> {
// Paths from `status` are repo-root-relative; paths clicked in the chat
// timeline are cwd-relative. Resolve whichever interpretation points at a
// real file (deleted files fall back to the root interpretation, which is
// also what `git show` uses).
const root = await repoToplevel(cwd);
let gitPath = relPath;
let full = path.join(root, relPath);
const existsAt = async (p: string) => fs.stat(p).then((s) => s.isFile()).catch(() => false);
if (!await existsAt(full)) {
const cwdFull = path.join(cwd, relPath);
if (await existsAt(cwdFull)) {
full = cwdFull;
// Realpath both sides — git reports the real toplevel, while the
// session cwd may reach it through a symlink (e.g. /tmp on macOS).
const realFull = await fs.realpath(cwdFull).catch(() => cwdFull);
gitPath = path.relative(root, realFull).split(path.sep).join('/');
}
}
let oldText = '';
try {
oldText = await git(cwd, ['show', `HEAD:${gitPath}`]);
} catch {
// untracked / newly added / no commits — diff against empty
oldText = '';
}
let newText = '';
try {
const stat = await fs.stat(full);
if (stat.size > MAX_TEXT_BYTES) {
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
}
newText = await fs.readFile(full, 'utf8');
} catch {
// deleted from working tree
newText = '';
}
if (oldText.length > MAX_TEXT_BYTES) {
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
}
if (oldText.includes('\0') || newText.includes('\0')) {
return { oldText: '', newText: '', isBinary: true, tooLarge: false };
}
return { oldText, newText, isBinary: false, tooLarge: false };
}
export async function worktreeAdd(repoPath: string, worktreePath: string, branch: string): Promise<void> {
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await git(repoPath, ['worktree', 'add', '-b', branch, worktreePath, 'HEAD']);
}
export async function worktreeRemove(
repoPath: string,
worktreePath: string,
opts: { force?: boolean; deleteBranch?: string } = {},
): Promise<void> {
try {
const args = ['worktree', 'remove'];
if (opts.force) args.push('--force');
args.push(worktreePath);
await git(repoPath, args);
} catch {
// The worktree dir may have been deleted by hand — prune the registration.
await git(repoPath, ['worktree', 'prune']).catch(() => {});
}
if (opts.deleteBranch) {
await git(repoPath, ['branch', '-D', opts.deleteBranch]).catch(() => {});
}
}
export interface MergeBackResult {
ok: boolean;
conflict?: boolean;
message: string;
}
// Merge the session branch into whatever the original checkout currently has
// checked out. Refuses on a dirty checkout; aborts cleanly on conflicts.
export async function mergeBack(repoPath: string, branch: string): Promise<MergeBackResult> {
const info = await repoInfo(repoPath);
if (!info.isGitRepo) {
return { ok: false, message: 'The project folder is not a git repository.' };
}
if (info.dirtyCount > 0) {
return {
ok: false,
message: `The repository at ${repoPath} has ${info.dirtyCount} uncommitted change(s). Commit or stash them, then merge again — or merge manually with: git merge ${branch}`,
};
}
try {
await git(repoPath, ['merge', '--no-edit', branch]);
return { ok: true, message: `Merged ${branch} into ${info.branch ?? 'the current branch'}.` };
} catch (e) {
await git(repoPath, ['merge', '--abort']).catch(() => {});
const detail = e instanceof Error ? e.message : String(e);
return {
ok: false,
conflict: true,
message: `Merge of ${branch} hit conflicts and was aborted. Resolve manually with: git merge ${branch}\n\n${detail.slice(0, 600)}`,
};
}
}

View file

@ -0,0 +1,83 @@
import fs from 'fs/promises';
import path from 'path';
// Contained file browsing for the Code section. Session cwds are arbitrary
// user directories (outside the Rowboat workspace), so every access resolves
// against the session root and is validated to stay inside it — realpath on
// the containing directory defeats both `..` traversal and symlink escapes.
const MAX_FILE_BYTES = 1024 * 1024;
async function resolveContained(root: string, relPath: string): Promise<string> {
if (path.isAbsolute(relPath)) {
throw new Error('Absolute paths are not allowed');
}
const realRoot = await fs.realpath(root);
const resolved = path.resolve(realRoot, relPath);
// Realpath the parent so symlinked ancestors can't escape...
const realParent = await fs.realpath(path.dirname(resolved)).catch(() => null);
if (realParent === null) {
throw new Error(`No such directory: ${relPath}`);
}
// ...and the target itself, so the final component being a symlink
// (e.g. a link to /etc) can't either. A missing target keeps its own path.
const joined = path.join(realParent, path.basename(resolved));
const realTarget = await fs.realpath(joined).catch(() => joined);
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) {
throw new Error('Path escapes the session directory');
}
return realTarget;
}
export interface ProjectDirEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
// One level at a time — the tree lazily expands, so node_modules costs nothing
// until the user opens it. `.git` is always hidden.
export async function readProjectDir(root: string, relPath: string): Promise<ProjectDirEntry[]> {
const target = await resolveContained(root, relPath || '.');
const dirents = await fs.readdir(target, { withFileTypes: true });
const entries: ProjectDirEntry[] = [];
for (const d of dirents) {
if (d.name === '.git') continue;
if (d.isDirectory()) {
entries.push({ name: d.name, kind: 'dir' });
} else if (d.isFile()) {
let size: number | undefined;
try {
size = (await fs.stat(path.join(target, d.name))).size;
} catch {
size = undefined;
}
entries.push({ name: d.name, kind: 'file', size });
}
// symlinks and other entry kinds are skipped
}
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
return entries;
}
export interface ProjectFileContent {
content: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function readProjectFile(root: string, relPath: string): Promise<ProjectFileContent> {
const target = await resolveContained(root, relPath);
const stat = await fs.stat(target);
if (!stat.isFile()) {
throw new Error(`Not a file: ${relPath}`);
}
if (stat.size > MAX_FILE_BYTES) {
return { content: '', isBinary: false, tooLarge: true };
}
const buf = await fs.readFile(target);
if (buf.includes(0)) {
return { content: '', isBinary: true, tooLarge: false };
}
return { content: buf.toString('utf8'), isBinary: false, tooLarge: false };
}

View file

@ -0,0 +1,69 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import z from 'zod';
import { CodeProject } from '@x/shared/dist/code-sessions.js';
const ProjectsFile = z.object({
projects: z.array(CodeProject),
});
export interface ICodeProjectsRepo {
list(): Promise<CodeProject[]>;
get(projectId: string): Promise<CodeProject | null>;
add(dirPath: string): Promise<CodeProject>;
remove(projectId: string): Promise<void>;
}
// Registered project directories for the Code section. One small JSON file —
// same pattern as the other config repos.
export class FSCodeProjectsRepo implements ICodeProjectsRepo {
private readonly configPath = path.join(WorkDir, 'config', 'code-projects.json');
private async read(): Promise<CodeProject[]> {
try {
const raw = await fs.readFile(this.configPath, 'utf8');
return ProjectsFile.parse(JSON.parse(raw)).projects;
} catch {
return [];
}
}
private async write(projects: CodeProject[]): Promise<void> {
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
await fs.writeFile(this.configPath, JSON.stringify({ projects }, null, 2));
}
async list(): Promise<CodeProject[]> {
return this.read();
}
async get(projectId: string): Promise<CodeProject | null> {
const projects = await this.read();
return projects.find((p) => p.id === projectId) ?? null;
}
async add(dirPath: string): Promise<CodeProject> {
const resolved = path.resolve(dirPath);
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
const projects = await this.read();
const existing = projects.find((p) => p.path === resolved);
if (existing) return existing;
const project: CodeProject = {
id: `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
path: resolved,
name: path.basename(resolved),
addedAt: new Date().toISOString(),
};
await this.write([...projects, project]);
return project;
}
async remove(projectId: string): Promise<void> {
const projects = await this.read();
await this.write(projects.filter((p) => p.id !== projectId));
}
}

View file

@ -0,0 +1,63 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import { CodeSession } from '@x/shared/dist/code-sessions.js';
// Mutable metadata for Code-section sessions, one JSON file per session
// (keyed by the session/run id). The immutable conversation itself lives in
// the run JSONL; the ACP resume state lives in code-mode/sessions/.
const META_DIR = path.join(WorkDir, 'code-mode', 'sessions-meta');
function metaFile(sessionId: string): string {
return path.join(META_DIR, `${sessionId}.json`);
}
export interface ICodeSessionsRepo {
list(): Promise<CodeSession[]>;
get(sessionId: string): Promise<CodeSession | null>;
save(session: CodeSession): Promise<void>;
remove(sessionId: string): Promise<void>;
}
export class FSCodeSessionsRepo implements ICodeSessionsRepo {
async list(): Promise<CodeSession[]> {
let names: string[] = [];
try {
names = (await fs.readdir(META_DIR)).filter((n) => n.endsWith('.json'));
} catch {
return [];
}
const sessions: CodeSession[] = [];
for (const name of names) {
try {
const raw = await fs.readFile(path.join(META_DIR, name), 'utf8');
sessions.push(CodeSession.parse(JSON.parse(raw)));
} catch {
// skip malformed files
}
}
// Newest activity first; session ids are time-sortable as a tiebreaker.
sessions.sort((a, b) =>
(b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt));
return sessions;
}
async get(sessionId: string): Promise<CodeSession | null> {
try {
const raw = await fs.readFile(metaFile(sessionId), 'utf8');
return CodeSession.parse(JSON.parse(raw));
} catch {
return null;
}
}
async save(session: CodeSession): Promise<void> {
const validated = CodeSession.parse(session);
await fs.mkdir(META_DIR, { recursive: true });
await fs.writeFile(metaFile(validated.id), JSON.stringify(validated, null, 2));
}
async remove(sessionId: string): Promise<void> {
await fs.rm(metaFile(sessionId), { force: true }).catch(() => {});
}
}

View file

@ -0,0 +1,361 @@
import path from 'path';
import fs from 'fs/promises';
import z from 'zod';
import { WorkDir } from '../../config/config.js';
import type { CodeSession, CodeSessionMode } from '@x/shared/dist/code-sessions.js';
import type { CodingAgent, ApprovalPolicy } from '@x/shared/dist/code-mode.js';
import { RunEvent, MessageEvent } from '@x/shared/dist/runs.js';
import type { IRunsRepo } from '../../runs/repo.js';
import type { IRunsLock } from '../../runs/lock.js';
import type { IBus } from '../../application/lib/bus.js';
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import type { IAbortRegistry } from '../../runs/abort-registry.js';
import type { CodeModeManager } from '../acp/manager.js';
import type { CodePermissionRegistry } from '../acp/permission-registry.js';
import type { ICodeSessionsRepo } from './repo.js';
import type { ICodeProjectsRepo } from '../projects/repo.js';
import { clearStoredSession } from '../acp/session-store.js';
import * as gitService from '../git/service.js';
export interface CreateSessionArgs {
projectId: string;
title?: string;
agent: CodingAgent;
mode: CodeSessionMode;
policy: ApprovalPolicy;
isolation: 'in-repo' | 'worktree';
// LLM for Rowboat-mode turns; unset falls through to the configured default.
model?: string;
provider?: string;
}
export interface SendMessageResult {
accepted: boolean;
error?: string;
}
function worktreeRoot(projectId: string, sessionId: string): string {
return path.join(WorkDir, 'code-mode', 'worktrees', projectId, sessionId);
}
// The per-run work directory the copilot anchors its general context to
// (same file the chat composer writes for regular chats). Keeping it in sync
// with the session cwd means Rowboat-mode turns see the right "# User Work
// Directory" even for tools other than code_agent_run.
async function persistRunWorkDir(runId: string, cwd: string): Promise<void> {
try {
const file = path.join(WorkDir, 'config', `workdir-${runId}.json`);
await fs.writeFile(file, JSON.stringify({ path: cwd }, null, 2));
} catch {
// best effort — the session meta still pins cwd for code_agent_run
}
}
// Drives Code-section sessions. A session is a run (same id) whose JSONL holds
// both modes' history: Rowboat turns are written by the agent runtime; direct
// turns are written here. The direct path talks straight to the ACP engine —
// no copilot LLM in between — but mirrors the runtime's lifecycle contract
// (runs lock, abort registry, processing-start/end, run-stopped) so the rest
// of the app (stop IPC, status tracking, event forwarding) needs no special
// casing.
export class CodeSessionService {
private readonly runsRepo: IRunsRepo;
private readonly runsLock: IRunsLock;
private readonly bus: IBus;
private readonly idGenerator: IMonotonicallyIncreasingIdGenerator;
private readonly abortRegistry: IAbortRegistry;
private readonly codeModeManager: CodeModeManager;
private readonly codePermissionRegistry: CodePermissionRegistry;
private readonly codeSessionsRepo: ICodeSessionsRepo;
private readonly codeProjectsRepo: ICodeProjectsRepo;
// Session ids with a direct prompt currently streaming (the runs lock also
// guards this, but we keep our own set to give a precise "busy" error).
private readonly inflight = new Set<string>();
constructor({
runsRepo,
runsLock,
bus,
idGenerator,
abortRegistry,
codeModeManager,
codePermissionRegistry,
codeSessionsRepo,
codeProjectsRepo,
}: {
runsRepo: IRunsRepo;
runsLock: IRunsLock;
bus: IBus;
idGenerator: IMonotonicallyIncreasingIdGenerator;
abortRegistry: IAbortRegistry;
codeModeManager: CodeModeManager;
codePermissionRegistry: CodePermissionRegistry;
codeSessionsRepo: ICodeSessionsRepo;
codeProjectsRepo: ICodeProjectsRepo;
}) {
this.runsRepo = runsRepo;
this.runsLock = runsLock;
this.bus = bus;
this.idGenerator = idGenerator;
this.abortRegistry = abortRegistry;
this.codeModeManager = codeModeManager;
this.codePermissionRegistry = codePermissionRegistry;
this.codeSessionsRepo = codeSessionsRepo;
this.codeProjectsRepo = codeProjectsRepo;
}
async create(args: CreateSessionArgs): Promise<CodeSession> {
const project = await this.codeProjectsRepo.get(args.projectId);
if (!project) throw new Error(`Unknown project: ${args.projectId}`);
// The session is a real run so Rowboat mode (agent runtime) works on it
// directly and the existing runs plumbing (fetch/events/stop) applies.
const { createRun } = await import('../../runs/runs.js');
const run = await createRun({
agentId: 'copilot',
useCase: 'code_session',
...(args.model ? { model: args.model } : {}),
...(args.provider ? { provider: args.provider } : {}),
});
const sessionId = run.id;
let cwd = project.path;
let worktree: CodeSession['worktree'];
if (args.isolation === 'worktree') {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo || !info.hasCommits) {
throw new Error('Worktree isolation needs a git repository with at least one commit.');
}
const branch = `rowboat/${sessionId}`;
const wtPath = worktreeRoot(project.id, sessionId);
await gitService.worktreeAdd(project.path, wtPath, branch);
worktree = { path: wtPath, branch, baseBranch: info.branch };
cwd = wtPath;
}
const session: CodeSession = {
id: sessionId,
projectId: project.id,
title: args.title?.trim() || `${project.name} session`,
agent: args.agent,
mode: args.mode,
policy: args.policy,
cwd,
...(worktree ? { worktree } : {}),
createdAt: new Date().toISOString(),
};
await this.codeSessionsRepo.save(session);
await persistRunWorkDir(sessionId, cwd);
return session;
}
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent'>>): Promise<CodeSession> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) throw new Error(`Unknown session: ${sessionId}`);
const updated: CodeSession = { ...session, ...patch };
await this.codeSessionsRepo.save(updated);
return updated;
}
isBusy(sessionId: string): boolean {
return this.inflight.has(sessionId);
}
// Direct drive: send the user's text straight to the session's ACP agent.
// Returns once the turn fully settles (the renderer streams via runs:events).
async sendMessage(sessionId: string, text: string): Promise<SendMessageResult> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) return { accepted: false, error: `Unknown session: ${sessionId}` };
if (this.inflight.has(sessionId)) {
return { accepted: false, error: 'The agent is still working on the previous message.' };
}
// The runs lock is shared with the agent runtime, so a Rowboat-mode turn
// in flight blocks direct sends (and vice versa) — the run JSONL never
// interleaves two writers.
if (!await this.runsLock.lock(sessionId)) {
return { accepted: false, error: 'The session is busy with a Rowboat-driven turn.' };
}
this.inflight.add(sessionId);
const signal = this.abortRegistry.createForRun(sessionId);
const turnId = await this.idGenerator.next();
const toolCallId = `direct-${turnId}`;
const appendAndPublish = async (event: z.infer<typeof RunEvent>) => {
await this.runsRepo.appendEvents(sessionId, [event]);
await this.bus.publish(event);
};
try {
await this.bus.publish({ runId: sessionId, type: 'run-processing-start', subflow: [] });
const userEvent: z.infer<typeof MessageEvent> = {
runId: sessionId,
type: 'message',
messageId: await this.idGenerator.next(),
message: { role: 'user', content: text },
subflow: [],
ts: new Date().toISOString(),
};
await appendAndPublish(userEvent);
await this.touch(session);
// Stream events live; persist the structural ones (tool calls, plan,
// resolved permissions). Streaming `message` chunks are NOT persisted —
// the agent's full text lands as one assistant MessageEvent below, which
// is also what lets a later Rowboat-mode turn see this conversation.
let finalText = '';
const persistQueue: Array<z.infer<typeof RunEvent>> = [];
const onAbort = () => this.codePermissionRegistry.cancelRun(sessionId);
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
let stopReason = 'cancelled';
try {
const result = await this.codeModeManager.runPrompt({
runId: sessionId,
agent: session.agent,
cwd: session.cwd,
prompt: text,
policy: session.policy,
signal,
suppressReplay: true,
onEvent: (event) => {
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
const streamEvent: z.infer<typeof RunEvent> = {
runId: sessionId,
type: 'code-run-event',
toolCallId,
event,
subflow: [],
};
void this.bus.publish(streamEvent);
if (event.type === 'tool_call' || event.type === 'tool_call_update'
|| event.type === 'plan' || event.type === 'permission') {
persistQueue.push({ ...streamEvent, ts: new Date().toISOString() });
}
},
ask: (permAsk) => this.codePermissionRegistry.request(sessionId, (requestId) => {
void this.bus.publish({
runId: sessionId,
type: 'code-run-permission-request',
toolCallId,
requestId,
ask: permAsk,
subflow: [],
});
}),
});
stopReason = result.stopReason;
} catch (error) {
if (!signal.aborted) {
const message = error instanceof Error ? (error.message || error.name) : String(error);
await appendAndPublish({ runId: sessionId, type: 'error', error: message, subflow: [] });
}
} finally {
signal.removeEventListener('abort', onAbort);
}
if (persistQueue.length > 0) {
await this.runsRepo.appendEvents(sessionId, persistQueue);
}
if (finalText.trim()) {
await appendAndPublish({
runId: sessionId,
type: 'message',
messageId: await this.idGenerator.next(),
message: { role: 'assistant', content: finalText },
subflow: [],
ts: new Date().toISOString(),
});
}
if (signal.aborted || stopReason === 'cancelled') {
await appendAndPublish({
runId: sessionId,
type: 'run-stopped',
reason: 'user-requested',
subflow: [],
});
}
await this.touch(session);
return { accepted: true };
} finally {
this.inflight.delete(sessionId);
this.abortRegistry.cleanup(sessionId);
await this.runsLock.release(sessionId);
await this.bus.publish({ runId: sessionId, type: 'run-processing-end', subflow: [] });
}
}
// Unblocks a stuck permission card immediately; the manager's signal handling
// (ACP cancel -> grace -> force-kill) actually unwinds the prompt.
async stop(sessionId: string): Promise<void> {
this.abortRegistry.abort(sessionId);
this.codePermissionRegistry.cancelRun(sessionId);
}
async mergeBack(sessionId: string): Promise<gitService.MergeBackResult> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session?.worktree) {
return { ok: false, message: 'This session has no isolated worktree to merge.' };
}
const project = await this.codeProjectsRepo.get(session.projectId);
if (!project) {
return { ok: false, message: 'The session\'s project is no longer registered.' };
}
const result = await gitService.mergeBack(project.path, session.worktree.branch);
if (result.ok) {
await this.codeSessionsRepo.save({
...session,
worktree: { ...session.worktree, mergedAt: new Date().toISOString() },
});
}
return result;
}
async cleanupWorktree(sessionId: string, deleteBranch: boolean): Promise<void> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session?.worktree || session.worktree.removedAt) return;
const project = await this.codeProjectsRepo.get(session.projectId);
// Drop any live agent connection on the worktree before deleting it.
this.codeModeManager.dispose(sessionId);
if (project) {
await gitService.worktreeRemove(project.path, session.worktree.path, {
force: true,
...(deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
});
}
const nextCwd = project?.path ?? session.cwd;
await this.codeSessionsRepo.save({
...session,
// The worktree is gone — fall back to working directly in the repo.
cwd: nextCwd,
worktree: { ...session.worktree, removedAt: new Date().toISOString() },
});
await persistRunWorkDir(sessionId, nextCwd);
}
async delete(sessionId: string, opts: { removeWorktree?: boolean; deleteBranch?: boolean } = {}): Promise<void> {
await this.stop(sessionId);
this.codeModeManager.dispose(sessionId);
const session = await this.codeSessionsRepo.get(sessionId);
if (opts.removeWorktree && session?.worktree && !session.worktree.removedAt) {
const project = await this.codeProjectsRepo.get(session.projectId);
if (project) {
await gitService.worktreeRemove(project.path, session.worktree.path, {
force: true,
...(opts.deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
});
}
}
await clearStoredSession(sessionId);
await this.codeSessionsRepo.remove(sessionId);
await this.runsRepo.delete(sessionId).catch(() => {});
await fs.rm(path.join(WorkDir, 'config', `workdir-${sessionId}.json`), { force: true }).catch(() => {});
}
private async touch(session: CodeSession): Promise<void> {
const current = await this.codeSessionsRepo.get(session.id);
if (!current) return;
await this.codeSessionsRepo.save({ ...current, lastActivityAt: new Date().toISOString() });
}
}

View file

@ -0,0 +1,136 @@
import z from 'zod';
import { RunEvent } from '@x/shared/dist/runs.js';
import type { IBus } from '../../application/lib/bus.js';
import type { ICodeSessionsRepo } from './repo.js';
import type { INotificationService } from '../../application/notification/service.js';
import type { CodeSessionStatus, CodeSession } from '@x/shared/dist/code-sessions.js';
import container from '../../di/container.js';
export type StatusListener = (sessionId: string, status: CodeSessionStatus) => void;
// Authoritative live status for Code-section sessions, derived in the main
// process from the run event stream. Works for both modes uniformly because
// direct turns and Rowboat-mode code_agent_run turns publish the same event
// types on the bus. The renderer just renders what this pushes.
export class CodeSessionStatusTracker {
private readonly bus: IBus;
private readonly codeSessionsRepo: ICodeSessionsRepo;
private readonly statuses = new Map<string, CodeSessionStatus>();
private readonly busySince = new Map<string, number>();
private readonly listeners = new Set<StatusListener>();
private unsubscribe: (() => void) | null = null;
// Session ids known to be code sessions; refreshed lazily on unknown ids so
// sessions created after start() are picked up without explicit wiring.
private knownSessions = new Set<string>();
// Ids confirmed NOT to be sessions (regular chat runs). Safe to cache
// permanently: a session's meta file is written before its first turn, so
// an id that misses the refresh can never become a session later.
private readonly knownNonSessions = new Set<string>();
constructor({ bus, codeSessionsRepo }: { bus: IBus; codeSessionsRepo: ICodeSessionsRepo }) {
this.bus = bus;
this.codeSessionsRepo = codeSessionsRepo;
}
async start(): Promise<void> {
if (this.unsubscribe) return;
await this.refreshKnownSessions();
this.unsubscribe = await this.bus.subscribe('*', async (event) => {
await this.handle(event);
});
}
stop(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
onTransition(listener: StatusListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getStatuses(): Record<string, CodeSessionStatus> {
return Object.fromEntries(this.statuses);
}
private async refreshKnownSessions(): Promise<void> {
const sessions = await this.codeSessionsRepo.list().catch(() => [] as CodeSession[]);
this.knownSessions = new Set(sessions.map((s) => s.id));
}
private async isCodeSession(runId: string): Promise<boolean> {
if (this.knownSessions.has(runId)) return true;
if (this.knownNonSessions.has(runId)) return false;
// Unknown id — maybe a session created since the last refresh.
await this.refreshKnownSessions();
if (this.knownSessions.has(runId)) return true;
this.knownNonSessions.add(runId);
return false;
}
private async handle(event: z.infer<typeof RunEvent>): Promise<void> {
const relevant = event.type === 'run-processing-start'
|| event.type === 'run-processing-end'
|| event.type === 'run-stopped'
|| event.type === 'error'
|| event.type === 'code-run-permission-request'
|| (event.type === 'code-run-event' && event.event.type === 'permission');
if (!relevant) return;
if (!await this.isCodeSession(event.runId)) return;
const previous = this.statuses.get(event.runId) ?? 'idle';
let next: CodeSessionStatus = previous;
switch (event.type) {
case 'run-processing-start':
next = 'working';
break;
case 'code-run-permission-request':
next = 'needs-you';
break;
case 'code-run-event':
// A permission resolution while the turn is still running.
if (previous === 'needs-you') next = 'working';
break;
case 'run-processing-end':
case 'run-stopped':
case 'error':
next = 'idle';
break;
}
if (next === previous) return;
if (previous === 'idle' && next !== 'idle') this.busySince.set(event.runId, Date.now());
this.statuses.set(event.runId, next);
for (const listener of this.listeners) listener(event.runId, next);
await this.notify(event.runId, previous, next);
if (next === 'idle') this.busySince.delete(event.runId);
}
private async notify(sessionId: string, previous: CodeSessionStatus, next: CodeSessionStatus): Promise<void> {
let notificationService: INotificationService;
try {
notificationService = container.resolve<INotificationService>('notificationService');
} catch {
return; // not registered (e.g. tests)
}
if (!notificationService.isSupported()) return;
const session = await this.codeSessionsRepo.get(sessionId);
const title = session?.title ?? 'Coding session';
if (next === 'needs-you') {
notificationService.notify({
title,
message: 'The coding agent needs your approval.',
});
} else if (next === 'idle' && previous === 'working') {
// Only worth interrupting for if the agent worked long enough that
// the user has plausibly moved on to something else.
const since = this.busySince.get(sessionId);
if (since !== undefined && Date.now() - since > 30_000) {
notificationService.notify({
title,
message: 'The coding agent finished its turn.',
});
}
}
}
}

View file

@ -3,8 +3,8 @@ import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import { CodeModeAgentStatus } from './types.js';
import { isEngineProvisioned } from './acp/engine-provisioner.js';
const execAsync = promisify(exec);
@ -40,30 +40,6 @@ export function commonInstallPaths(binary: string): string[] {
].map(dir => path.join(dir, binary));
}
async function probeShell(binary: string): Promise<boolean> {
try {
if (process.platform === 'win32') {
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
return stdout.trim().length > 0;
}
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}
async function isInstalled(binary: string): Promise<boolean> {
if (await probeShell(binary)) return true;
// Fallback: scan well-known install locations directly.
for (const candidate of commonInstallPaths(binary)) {
if (existsSync(candidate)) return true;
}
return false;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
@ -186,14 +162,15 @@ async function checkCodexSignedIn(): Promise<boolean> {
export { decodeJwtPayload };
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
isInstalled('claude'),
isInstalled('codex'),
const [claudeSignedIn, codexSignedIn] = await Promise.all([
checkClaudeSignedIn(),
checkCodexSignedIn(),
]);
// `installed` means the engine is provisioned (downloaded) locally — the user has
// clicked Enable in Settings → Code Mode. We no longer look for a global claude/codex
// CLI on PATH; code mode runs our own pinned engine from ~/.rowboat/engines.
return {
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
codex: { installed: codexInstalled, signedIn: codexSignedIn },
claude: { installed: isEngineProvisioned('claude'), signedIn: claudeSignedIn },
codex: { installed: isEngineProvisioned('codex'), signedIn: codexSignedIn },
};
}

View file

@ -18,6 +18,10 @@ import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-sche
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import { CodeModeManager } from "../code-mode/acp/manager.js";
import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js";
import { FSCodeProjectsRepo, ICodeProjectsRepo } from "../code-mode/projects/repo.js";
import { FSCodeSessionsRepo, ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
import { CodeSessionService } from "../code-mode/sessions/service.js";
import { CodeSessionStatusTracker } from "../code-mode/sessions/status-tracker.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
@ -51,6 +55,13 @@ container.register({
// session/load); the registry brokers mid-run approvals.
codeModeManager: asClass(CodeModeManager).singleton(),
codePermissionRegistry: asClass(CodePermissionRegistry).singleton(),
// Code section: project registry, session metadata, the direct-drive
// session service, and the live status tracker.
codeProjectsRepo: asClass<ICodeProjectsRepo>(FSCodeProjectsRepo).singleton(),
codeSessionsRepo: asClass<ICodeSessionsRepo>(FSCodeSessionsRepo).singleton(),
codeSessionService: asClass(CodeSessionService).singleton(),
codeSessionStatusTracker: asClass(CodeSessionStatusTracker).singleton(),
});
export default container;

View file

@ -298,15 +298,19 @@ export class FSRunsRepo implements IRunsRepo {
for (const name of selected) {
const runId = name.slice(0, -'.jsonl'.length);
const metadata = await this.readRunMetadata(path.join(runsDir, name));
const filePath = path.join(runsDir, name);
const metadata = await this.readRunMetadata(filePath);
if (!metadata) {
continue;
}
const stat = await fsp.stat(filePath);
runs.push({
id: runId,
title: metadata.title,
createdAt: metadata.start.ts!,
modifiedAt: stat.mtime.toISOString(),
agentId: metadata.start.agentName,
...(metadata.start.useCase ? { useCase: metadata.start.useCase } : {}),
});
}

View file

@ -3,6 +3,7 @@ import container from "../di/container.js";
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js";
@ -40,9 +41,23 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run;
}
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex', codeCwd?: string, codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo'): Promise<string> {
// Code-section sessions carry their coding context in the session meta.
// Pin it here — not in the composer — so EVERY path into the run (assistant
// chat pane, voice, palette) drives the session's agent in its directory,
// and the session header stays the single source of truth.
try {
const sessionMeta = await container.resolve<ICodeSessionsRepo>('codeSessionsRepo').get(runId);
if (sessionMeta) {
codeMode = sessionMeta.agent;
codeCwd = sessionMeta.cwd;
codePolicy = sessionMeta.policy;
}
} catch {
// sessions repo unavailable — treat as a regular chat run
}
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode, codeCwd, codePolicy);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
return id;

View file

@ -1,5 +1,6 @@
import chokidar, { type FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import path from 'node:path';
import { ensureWorkspaceRoot, absToRelPosix } from './workspace.js';
import { WorkDir } from '../config/config.js';
import { WorkspaceChangeEvent } from 'packages/shared/dist/workspace.js';
@ -21,8 +22,15 @@ export async function createWorkspaceWatcher(
): Promise<FSWatcher> {
await ensureWorkspaceRoot();
// Code-section session worktrees are full repo checkouts (thousands of files,
// possibly node_modules) living under WorkDir — watching them would flood the
// event stream and burn file handles, and nothing in the app renders them
// from workspace events.
const codeModeDir = path.join(WorkDir, 'code-mode');
const watcher = chokidar.watch(WorkDir, {
ignoreInitial: true,
ignored: (watchedPath: string) =>
watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep),
awaitWriteFinish: {
stabilityThreshold: 150,
pollInterval: 50,

View file

@ -0,0 +1,71 @@
import z from "zod";
import { CodingAgent, ApprovalPolicy } from "./code-mode.js";
// Shared zod schemas for the Code section: registered projects and coding
// sessions. A coding session is backed by a run (session id == run id); the
// mutable metadata below lives in its own per-session file.
export const CodeProject = z.object({
id: z.string(),
path: z.string(),
name: z.string(),
addedAt: z.iso.datetime(),
});
export type CodeProject = z.infer<typeof CodeProject>;
// Git facts about a project path, used to gate worktree creation in the UI.
export const GitRepoInfo = z.object({
isGitRepo: z.boolean(),
branch: z.string().nullable(),
hasCommits: z.boolean(),
dirtyCount: z.number(),
});
export type GitRepoInfo = z.infer<typeof GitRepoInfo>;
// 'direct': the user's messages go straight to the ACP coding agent.
// 'rowboat': Rowboat's copilot LLM orchestrates the agent via code_agent_run.
export const CodeSessionMode = z.enum(["direct", "rowboat"]);
export type CodeSessionMode = z.infer<typeof CodeSessionMode>;
// Derived live in the main process from the run event stream; not persisted.
export const CodeSessionStatus = z.enum(["working", "needs-you", "idle"]);
export type CodeSessionStatus = z.infer<typeof CodeSessionStatus>;
export const CodeWorktree = z.object({
path: z.string(),
branch: z.string(),
// Branch the original checkout was on when the worktree was created;
// merge-back targets whatever the checkout is on at merge time, this is
// informational.
baseBranch: z.string().nullable(),
mergedAt: z.iso.datetime().optional(),
removedAt: z.iso.datetime().optional(),
});
export type CodeWorktree = z.infer<typeof CodeWorktree>;
export const CodeSession = z.object({
id: z.string(), // == runId
projectId: z.string(),
title: z.string(),
agent: CodingAgent,
mode: CodeSessionMode,
policy: ApprovalPolicy,
// Where the agent works: the project path, or the worktree path.
cwd: z.string(),
worktree: CodeWorktree.optional(),
createdAt: z.iso.datetime(),
lastActivityAt: z.iso.datetime().optional(),
});
export type CodeSession = z.infer<typeof CodeSession>;
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
export type GitFileState = z.infer<typeof GitFileState>;
export const GitStatusFile = z.object({
path: z.string(),
state: GitFileState,
// Null when git can't compute line counts (binary files).
insertions: z.number().nullable(),
deletions: z.number().nullable(),
});
export type GitStatusFile = z.infer<typeof GitStatusFile>;

View file

@ -18,4 +18,5 @@ export * as bases from './bases.js';
export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as notificationSettings from './notification-settings.js';
export * as codeSessions from './code-sessions.js';
export { PrefixLogger };

View file

@ -19,8 +19,9 @@ import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -274,6 +275,10 @@ const ipcSchemas = {
voiceOutput: z.enum(['summary', 'full']).optional(),
searchEnabled: z.boolean().optional(),
codeMode: z.enum(['claude', 'codex']).optional(),
// Code-section sessions pin the coding agent's working directory and
// approval policy for the whole turn (see code_agent_run overrides).
codeCwd: z.string().optional(),
codePolicy: ApprovalPolicy.optional(),
middlePaneContext: z.discriminatedUnion('kind', [
z.object({
kind: z.literal('note'),
@ -503,6 +508,234 @@ const ipcSchemas = {
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
}),
},
// Download + install an agent's native engine (the Settings "Enable" action).
// Streams progress over the 'codeMode:engineProgress' push channel while it runs.
'codeMode:provisionEngine': {
req: z.object({ agent: z.enum(['claude', 'codex']) }),
res: z.object({ success: z.boolean(), error: z.string().optional() }),
},
// Push (main -> renderer): engine provisioning progress for the Settings UI.
'codeMode:engineProgress': {
req: z.object({
agent: z.enum(['claude', 'codex']),
phase: z.enum(['download', 'verify', 'extract', 'done']),
receivedBytes: z.number().optional(),
totalBytes: z.number().optional(),
}),
res: z.null(),
},
// ==========================================================================
// Code section: project registry + coding sessions
// ==========================================================================
'codeProject:add': {
req: z.object({
path: z.string(),
}),
res: z.object({
project: CodeProject,
git: GitRepoInfo,
}),
},
'codeProject:remove': {
req: z.object({
projectId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeProject:list': {
req: z.null(),
res: z.object({
projects: z.array(z.object({
project: CodeProject,
git: GitRepoInfo,
})),
}),
},
'codeSession:create': {
req: z.object({
projectId: z.string(),
title: z.string().optional(),
agent: CodingAgent,
mode: CodeSessionMode,
policy: ApprovalPolicy,
isolation: z.enum(['in-repo', 'worktree']),
// LLM for Rowboat-mode turns. Unset = the configured default. Like any
// chat, the model is fixed once the session's run exists.
model: z.string().optional(),
provider: z.string().optional(),
}),
res: z.object({
session: CodeSession,
}),
},
'codeSession:list': {
req: z.null(),
res: z.object({
sessions: z.array(CodeSession),
statuses: z.record(z.string(), CodeSessionStatus),
}),
},
'codeSession:update': {
req: z.object({
sessionId: z.string(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(),
}),
res: z.object({
session: CodeSession,
}),
},
'codeSession:delete': {
req: z.object({
sessionId: z.string(),
removeWorktree: z.boolean().optional(),
deleteBranch: z.boolean().optional(),
}),
res: z.object({
success: z.literal(true),
}),
},
// Direct-drive: send the user's message straight to the session's ACP agent
// (no copilot LLM in between). Streams back over `runs:events`.
'codeSession:sendMessage': {
req: z.object({
sessionId: z.string(),
text: z.string().min(1),
}),
res: z.object({
accepted: z.boolean(),
error: z.string().optional(),
}),
},
'codeSession:stop': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeSession:gitStatus': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
isRepo: z.boolean(),
branch: z.string().nullable(),
hasCommits: z.boolean(),
files: z.array(GitStatusFile),
}),
},
'codeSession:fileDiff': {
req: z.object({
sessionId: z.string(),
path: z.string(),
}),
res: z.object({
oldText: z.string(),
newText: z.string(),
isBinary: z.boolean(),
tooLarge: z.boolean(),
}),
},
'codeSession:readdir': {
req: z.object({
sessionId: z.string(),
relPath: z.string(),
}),
res: z.object({
entries: z.array(z.object({
name: z.string(),
kind: z.enum(['file', 'dir']),
size: z.number().optional(),
})),
}),
},
'codeSession:readFile': {
req: z.object({
sessionId: z.string(),
relPath: z.string(),
}),
res: z.object({
content: z.string(),
isBinary: z.boolean(),
tooLarge: z.boolean(),
}),
},
'codeSession:mergeBack': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
ok: z.boolean(),
conflict: z.boolean().optional(),
message: z.string(),
}),
},
'codeSession:cleanupWorktree': {
req: z.object({
sessionId: z.string(),
deleteBranch: z.boolean(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
// main → renderer: live session status transitions from the status tracker.
'codeSession:status': {
req: z.object({
sessionId: z.string(),
status: CodeSessionStatus,
}),
res: z.null(),
},
// ==========================================================================
// Embedded terminal (Code section): one PTY per coding session
// ==========================================================================
// Create-or-attach. Returns the scrollback backlog so a remounted view can
// repaint what happened while it was closed.
'terminal:ensure': {
req: z.object({
id: z.string(),
cwd: z.string(),
cols: z.number().int().positive(),
rows: z.number().int().positive(),
}),
res: z.object({
backlog: z.string(),
running: z.boolean(),
}),
},
'terminal:input': {
req: z.object({
id: z.string(),
data: z.string(),
}),
res: z.object({ success: z.literal(true) }),
},
'terminal:resize': {
req: z.object({
id: z.string(),
cols: z.number().int().positive(),
rows: z.number().int().positive(),
}),
res: z.object({ success: z.literal(true) }),
},
'terminal:dispose': {
req: z.object({ id: z.string() }),
res: z.object({ success: z.literal(true) }),
},
// main → renderer streams
'terminal:data': {
req: z.object({ id: z.string(), data: z.string() }),
res: z.null(),
},
'terminal:exit': {
req: z.object({ id: z.string(), exitCode: z.number() }),
res: z.null(),
},
'granola:setConfig': {
req: z.object({
enabled: z.boolean(),
@ -774,6 +1007,15 @@ const ipcSchemas = {
path: z.string().nullable(),
}),
},
'dialog:openFiles': {
req: z.object({
defaultPath: z.string().optional(),
title: z.string().optional(),
}),
res: z.object({
paths: z.array(z.string()),
}),
},
// Knowledge version history channels
'knowledge:history': {
req: z.object({ path: RelPath }),

View file

@ -31,6 +31,7 @@ export const StartEvent = BaseRunEvent.extend({
"background_task_agent",
"meeting_note",
"knowledge_sync",
"code_session",
]).optional(),
subUseCase: z.string().optional(),
});
@ -188,6 +189,7 @@ export const UseCase = z.enum([
"background_task_agent",
"meeting_note",
"knowledge_sync",
"code_session",
]);
export const Run = z.object({
@ -209,6 +211,11 @@ export const ListRunsResponse = z.object({
title: true,
createdAt: true,
agentId: true,
useCase: true,
}).extend({
// Last-modified time of the run's log file (mtime), used to order the
// chat history by recent activity rather than creation time.
modifiedAt: z.iso.datetime(),
})),
nextCursor: z.string().optional(),
});

644
apps/x/pnpm-lock.yaml generated
View file

@ -79,6 +79,9 @@ importers:
mammoth:
specifier: ^1.11.0
version: 1.11.0
node-pty:
specifier: ^1.1.0
version: 1.1.0
papaparse:
specifier: ^5.5.3
version: 5.5.3
@ -150,9 +153,27 @@ importers:
apps/renderer:
dependencies:
'@codemirror/language':
specifier: ^6.12.3
version: 6.12.3
'@codemirror/language-data':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/merge':
specifier: ^6.12.2
version: 6.12.2
'@codemirror/state':
specifier: ^6.6.0
version: 6.6.0
'@codemirror/view':
specifier: ^6.43.1
version: 6.43.1
'@eigenpal/docx-editor-react':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
'@radix-ui/react-avatar':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -212,16 +233,16 @@ importers:
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder':
specifier: 3.22.4
version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-table':
specifier: 3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-task-item':
specifier: 3.22.4
version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-task-list':
specifier: 3.22.4
version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/pm':
specifier: 3.22.4
version: 3.22.4
@ -237,6 +258,12 @@ importers:
'@x/shared':
specifier: workspace:*
version: link:../../packages/shared
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
ai:
specifier: ^5.0.117
version: 5.0.117(zod@4.2.1)
@ -249,6 +276,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
codemirror:
specifier: ^6.0.2
version: 6.0.2
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
@ -602,25 +632,21 @@ packages:
resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156':
resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156':
resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156':
resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156':
resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==}
@ -923,6 +949,99 @@ packages:
'@chevrotain/utils@12.0.0':
resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==}
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.10.3':
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/lang-jinja@6.0.1':
resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-liquid@6.3.2':
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.3':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/language-data@6.5.2':
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
'@codemirror/language@6.12.3':
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
'@codemirror/legacy-modes@6.5.3':
resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/merge@6.12.2':
resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==}
'@codemirror/search@6.7.0':
resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==}
'@codemirror/state@6.6.0':
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
'@codemirror/view@6.43.1':
resolution: {integrity: sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==}
'@composio/client@0.1.0-alpha.56':
resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==}
@ -1627,6 +1746,57 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/cpp@1.1.6':
resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
'@lezer/css@1.3.3':
resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@lezer/markdown@1.6.4':
resolution: {integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
'@lezer/python@1.1.19':
resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@listr2/prompt-adapter-inquirer@2.0.22':
resolution: {integrity: sha512-hV36ZoY+xKL6pYOt1nPNnkciFkn89KZwqLhAFzJvYysAvL5uBQdiADZx/8bIDXIukzzwG0QlPYolgMzQUtKgpQ==}
engines: {node: '>=18.0.0'}
@ -1641,6 +1811,9 @@ packages:
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@mermaid-js/parser@1.1.0':
resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==}
@ -1686,35 +1859,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.80':
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
@ -2878,67 +3046,56 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
@ -3272,28 +3429,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@ -3429,12 +3582,6 @@ packages:
peerDependencies:
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-list@3.22.4':
resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-list@3.22.5':
resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==}
peerDependencies:
@ -3487,12 +3634,6 @@ packages:
peerDependencies:
'@tiptap/core': 3.22.5
'@tiptap/extensions@3.22.4':
resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extensions@3.22.5':
resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==}
peerDependencies:
@ -3956,6 +4097,12 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@ -4393,6 +4540,9 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
@ -4502,6 +4652,9 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cron-parser@5.5.0:
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
engines: {node: '>=18'}
@ -6034,28 +6187,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@ -6586,6 +6735,9 @@ packages:
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
@ -6618,6 +6770,9 @@ packages:
node-html-parser@6.1.13:
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@ -7663,6 +7818,9 @@ packages:
strnum@2.1.2:
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@ -9106,6 +9264,265 @@ snapshots:
'@chevrotain/utils@12.0.0': {}
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.3':
dependencies:
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/cpp': 1.1.6
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/css': 1.3.3
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/css': 1.3.3
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.4
'@codemirror/lang-jinja@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-liquid@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/markdown': 1.6.4
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.3':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
'@codemirror/language-data@6.5.2':
dependencies:
'@codemirror/lang-angular': 0.1.4
'@codemirror/lang-cpp': 6.0.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-go': 6.0.1
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-java': 6.0.2
'@codemirror/lang-javascript': 6.2.5
'@codemirror/lang-jinja': 6.0.1
'@codemirror/lang-json': 6.0.2
'@codemirror/lang-less': 6.0.2
'@codemirror/lang-liquid': 6.3.2
'@codemirror/lang-markdown': 6.5.0
'@codemirror/lang-php': 6.0.2
'@codemirror/lang-python': 6.2.1
'@codemirror/lang-rust': 6.0.2
'@codemirror/lang-sass': 6.0.2
'@codemirror/lang-sql': 6.10.0
'@codemirror/lang-vue': 0.1.3
'@codemirror/lang-wast': 6.0.2
'@codemirror/lang-xml': 6.1.0
'@codemirror/lang-yaml': 6.1.3
'@codemirror/language': 6.12.3
'@codemirror/legacy-modes': 6.5.3
'@codemirror/language@6.12.3':
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.3':
dependencies:
'@codemirror/language': 6.12.3
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
crelt: 1.0.6
'@codemirror/merge@6.12.2':
dependencies:
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
'@lezer/highlight': 1.2.3
style-mod: 4.1.3
'@codemirror/search@6.7.0':
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
crelt: 1.0.6
'@codemirror/state@6.6.0':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.43.1':
dependencies:
'@codemirror/state': 6.6.0
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@composio/client@0.1.0-alpha.56': {}
'@composio/core@0.6.2(zod@4.2.1)':
@ -9993,6 +10410,99 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.2': {}
'@lezer/cpp@1.1.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/css@1.3.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.2
'@lezer/markdown@1.6.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/python@1.1.19':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@6.0.1)':
dependencies:
'@inquirer/prompts': 6.0.1
@ -10007,6 +10517,8 @@ snapshots:
dependencies:
cross-spawn: 7.0.6
'@marijn/find-cluster-break@1.0.2': {}
'@mermaid-js/parser@1.1.0':
dependencies:
langium: 4.2.2
@ -11912,11 +12424,6 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
@ -11930,9 +12437,9 @@ snapshots:
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
@ -11943,13 +12450,13 @@ snapshots:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
@ -11959,11 +12466,6 @@ snapshots:
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
@ -12601,6 +13103,10 @@ snapshots:
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
'@xtuc/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {}
@ -13077,6 +13583,16 @@ snapshots:
- '@types/react'
- '@types/react-dom'
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.10.3
'@codemirror/language': 6.12.3
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.0
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.1
codepage@1.15.0: {}
color-convert@0.5.3:
@ -13159,6 +13675,8 @@ snapshots:
crc-32@1.2.2: {}
crelt@1.0.6: {}
cron-parser@5.5.0:
dependencies:
luxon: 3.7.2
@ -15794,6 +16312,8 @@ snapshots:
dependencies:
semver: 7.7.3
node-addon-api@7.1.1: {}
node-api-version@0.2.1:
dependencies:
semver: 7.7.3
@ -15828,6 +16348,10 @@ snapshots:
css-select: 5.2.2
he: 1.2.0
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.27: {}
nopt@6.0.0:
@ -17079,6 +17603,8 @@ snapshots:
strnum@2.1.2: {}
style-mod@4.1.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14

View file

@ -3,13 +3,14 @@ packages:
- packages/*
allowBuilds:
core-js: set this to true or false
electron: set this to true or false
electron-winstaller: set this to true or false
esbuild: set this to true or false
fs-xattr: set this to true or false
macos-alias: set this to true or false
protobufjs: set this to true or false
core-js: true
electron: true
electron-winstaller: true
esbuild: true
fs-xattr: true
macos-alias: true
node-pty: true
protobufjs: true
catalog:
vitest: 4.1.7