Merge origin/dev into drive

Resolve conflicts:
- apps/main/src/ipc.ts: import union (dev's gmail contacts/getAccountName +
  drive's google_docs/managed-picker imports).
- apps/renderer/src/App.tsx: import union (dev's CodingRunBlock/KnowledgeViewMode
  + drive's GoogleDocPickerDialog).
- apps/renderer/src/components/knowledge-view.tsx: keep the "Add Google Doc"
  button in the header next to the voice-note action; Search/Graph/New note are
  now dev's QuickActions / view-mode toggles.
This commit is contained in:
Gagancreates 2026-06-23 02:58:51 +05:30
commit 462023f4c2
142 changed files with 17080 additions and 1347 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,4 +49,51 @@ await esbuild.build({
},
});
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
// package dir and copy only what's needed at runtime (compiled JS + prebuilt
// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
// the bit, and a non-executable helper makes every PTY spawn fail.
const here = path.dirname(fileURLToPath(import.meta.url));
const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
fs.rmSync(ptyDest, { recursive: true, force: true });
fs.mkdirSync(ptyDest, { recursive: true });
for (const item of ['package.json', 'lib', 'prebuilds']) {
fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
}
const prebuildsDir = path.join(ptyDest, 'prebuilds');
for (const dir of fs.readdirSync(prebuildsDir)) {
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
}
console.log('✅ node-pty staged in .package/node_modules');
// Bundle the vendored agent-slack CLI into a single self-contained script next
// to main.cjs. It runs as a child process (process.execPath with
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
// be inlined into main.cjs. Bundling here means the packaged app needs neither
// node_modules nor a global npm install.
const agentSlackPkg = JSON.parse(
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
);
await esbuild.build({
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
bundle: true,
platform: 'node',
target: 'node22',
outfile: './.package/dist/agent-slack.cjs',
format: 'cjs',
banner: { js: cjsBanner },
define: {
'import.meta.url': '__import_meta_url',
// Without this constant the CLI's --version walks up the directory tree
// for a package.json and would find Rowboat's instead of agent-slack's.
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
},
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
// back to node:sqlite; keep it external so the probe fails at runtime the
// same way it does under plain node.
external: ['bun:sqlite'],
});
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);

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: [
{
@ -56,6 +148,7 @@ module.exports = {
description: 'AI coworker with memory',
name: `Rowboat-win32-${arch}`,
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
setupIcon: path.join(__dirname, 'icons/icon.ico'),
})
},
{
@ -66,7 +159,9 @@ module.exports = {
bin: "rowboat",
description: 'AI coworker with memory',
maintainer: 'rowboatlabs',
homepage: 'https://rowboatlabs.com'
homepage: 'https://rowboatlabs.com',
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
}
})
},
@ -77,10 +172,27 @@ module.exports = {
name: `Rowboat-linux`,
bin: "rowboat",
description: 'AI coworker with memory',
homepage: 'https://rowboatlabs.com'
homepage: 'https://rowboatlabs.com',
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
}
}
},
{
name: require.resolve('./makers/maker-pacman.cjs'),
platforms: ['linux'],
config: {
name: 'rowboat',
bin: 'rowboat',
executableName: 'rowboat',
description: 'AI coworker with memory',
maintainer: 'rowboatlabs',
homepage: 'https://rowboatlabs.com',
license: 'Apache',
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
}
},
{
name: '@electron-forge/maker-zip',
platform: ["darwin", "win32", "linux"],
@ -173,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/');
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,134 @@
// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages
// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host).
//
// CJS on purpose: forge.config.cjs require()s us.
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const MakerBase = require('@electron-forge/maker-base').default;
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' };
class MakerPacman extends MakerBase {
name = 'pacman';
defaultPlatforms = ['linux'];
isSupportedOnCurrentPlatform() {
if (process.platform !== 'linux') return false;
try {
execSync('command -v makepkg', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
async make({ dir, makeDir, targetArch, packageJSON, appName }) {
const pkgArch = ARCH_MAP[targetArch] || targetArch;
const cfg = this.config || {};
const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase();
// pacman pkgver disallows '-'; map prerelease tags through.
const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_');
const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"');
const maintainer = cfg.maintainer || 'unknown';
const homepage = cfg.homepage || packageJSON.homepage || '';
const license = cfg.license || 'custom';
const bin = cfg.bin || pkgName;
const execName = cfg.executableName || appName || pkgName;
const mimeTypes = cfg.mimeType || [];
const depends = cfg.depends || [];
const iconSrc = cfg.icon;
const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch));
await this.ensureDirectory(outDir);
// Clean prior contents so makepkg starts fresh each run.
for (const f of fs.readdirSync(outDir)) {
fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
}
// Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs).
fs.writeFileSync(
path.join(outDir, bin),
`#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`,
{ mode: 0o755 },
);
const desktop = [
'[Desktop Entry]',
`Name=${appName || pkgName}`,
`Comment=${pkgDesc}`,
`Exec=${bin} %U`,
`Icon=${pkgName}`,
'Type=Application',
'Categories=Utility;',
'Terminal=false',
mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null,
'',
].filter(Boolean).join('\n');
fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop);
const sources = [bin, `${pkgName}.desktop`];
let iconInstall = '';
if (iconSrc && fs.existsSync(iconSrc)) {
fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png'));
sources.push('icon.png');
iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`;
}
const sumsLine = sources.map(() => "'SKIP'").join(' ');
const sourceLine = sources.map((s) => `'${s}'`).join(' ');
const dependsLine = depends.map((d) => `'${d}'`).join(' ');
// Embed the packager output dir as a bash-safe literal.
const appDirEscaped = dir.replace(/'/g, `'\\''`);
const pkgbuild = `# Maintainer: ${maintainer}
# Auto-generated by maker-pacman.cjs do not edit by hand.
pkgname=${pkgName}
pkgver=${pkgVersion}
pkgrel=1
pkgdesc="${pkgDesc}"
arch=('${pkgArch}')
url="${homepage}"
license=('${license}')
depends=(${dependsLine})
options=('!strip' '!debug')
source=(${sourceLine})
sha256sums=(${sumsLine})
_appdir='${appDirEscaped}'
package() {
install -dm755 "$pkgdir/opt/$pkgname"
cp -a "$_appdir/." "$pkgdir/opt/$pkgname/"
# Electron's sandbox helper needs setuid root for the namespace sandbox.
if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then
chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
fi
install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}"
install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop"
${iconInstall}
}
`;
fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild);
execSync('makepkg -f --noconfirm --nodeps', {
cwd: outDir,
stdio: 'inherit',
env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch },
});
return fs
.readdirSync(outDir)
.filter((f) => f.endsWith('.pkg.tar.zst'))
.map((f) => path.join(outDir, f));
}
}
module.exports = MakerPacman;
module.exports.default = MakerPacman;

View file

@ -13,12 +13,16 @@
"make": "electron-forge make"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
"@agentclientprotocol/codex-acp": "^0.0.44",
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
"agent-slack": "0.9.3",
"chokidar": "^4.0.3",
"electron-squirrel-startup": "^1.0.1",
"html-to-docx": "^1.8.0",
"mammoth": "^1.11.0",
"node-pty": "^1.1.0",
"papaparse": "^5.5.3",
"pdf-parse": "^2.4.5",
"update-electron-app": "^3.1.2",
@ -27,6 +31,7 @@
},
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-base": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-rpm": "^7.11.1",

View file

@ -16,27 +16,45 @@ import { bus } from '@x/core/dist/runs/bus.js';
import { serviceBus } from '@x/core/dist/services/service_bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import { exec } from 'node:child_process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import z from 'zod';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection } from '@x/core/dist/models/models.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
import { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js';
import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
import type { CodeSession } from '@x/shared/dist/code-sessions.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import * as composioHandler from './composio-handler.js';
import { consumePendingDeepLink } from './deeplink.js';
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
@ -51,7 +69,9 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
import { startManagedGooglePick } from './google-picker-managed.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
@ -74,6 +94,190 @@ import {
listTasks,
readRunIds as readTaskRunIds,
} from '@x/core/dist/background-tasks/fileops.js';
type SlackHomeChannel = {
id: string;
name: string;
workspaceUrl?: string;
workspaceName?: string;
};
type SlackHomeMessage = {
id: string;
workspaceName?: string;
workspaceUrl?: string;
channelId?: string;
channelName?: string;
author?: string;
text: string;
ts: string;
url?: string;
};
function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
return (parsed.workspaces || []).map((w) => ({
url: w.workspace_url || '',
name: w.workspace_name || '',
}));
}
type SlackAuthResult = {
ok: boolean;
workspaces: Array<{ url: string; name: string }>;
error?: string;
errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
};
// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
// Shared by the plain and the quit-Slack-first import handlers.
async function importDesktopAndReadWorkspaces(): Promise<SlackAuthResult> {
const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false });
if (!imported.ok) {
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
}
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!whoami.ok) {
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
}
const workspaces = parseWhoamiWorkspaces(whoami.data);
if (workspaces.length === 0) {
return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' };
}
return { ok: true, workspaces };
}
// Windows force-quits Slack so its exclusive Cookies-DB lock releases before
// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with
// Slack open. taskkill exits non-zero when nothing matches — that's fine.
async function quitSlackIfWindows(): Promise<void> {
if (process.platform !== 'win32') return;
try {
await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true });
} catch {
// No running Slack process to kill — nothing to do.
}
// Give Windows a moment to release the file handles before we copy them.
await new Promise(resolve => setTimeout(resolve, 800));
}
function extractArrayPayload(parsed: unknown): unknown[] {
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
for (const key of ['messages', 'channels', 'items', 'results', 'data']) {
if (Array.isArray(obj[key])) return obj[key] as unknown[];
}
}
return [];
}
function slackMessageText(message: Record<string, unknown>): string {
const value = message.text ?? message.body ?? message.content;
return typeof value === 'string' ? value.trim() : '';
}
function slackMessageAuthor(message: Record<string, unknown>): string | undefined {
const value = message.username ?? message.user ?? message.author;
return typeof value === 'string' ? value : undefined;
}
function extractSlackUserName(raw: unknown): string | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record<string, unknown> : undefined;
const user = obj.user && typeof obj.user === 'object' ? obj.user as Record<string, unknown> : undefined;
const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record<string, unknown> : undefined;
const candidates = [
profile?.display_name,
profile?.real_name,
userProfile?.display_name,
userProfile?.real_name,
obj.display_name,
obj.displayName,
obj.real_name,
obj.realName,
user?.display_name,
user?.displayName,
user?.real_name,
user?.realName,
obj.name,
user?.name,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim();
}
}
return null;
}
async function resolveSlackUserName(
userId: string,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string | null> {
const key = `${workspaceUrl ?? ''}:${userId}`;
if (cache.has(key)) return cache.get(key) ?? null;
const args = ['user', 'get', userId];
if (workspaceUrl) {
args.push('--workspace', workspaceUrl);
}
const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 });
if (result.ok) {
const name = extractSlackUserName(result.data ?? {});
if (name) {
cache.set(key, name);
return name;
}
} else {
console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`);
}
cache.set(key, userId);
return null;
}
async function resolveSlackMessageText(
text: string,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string> {
const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g));
if (matches.length === 0) return text;
let resolved = text;
for (const match of matches) {
const userId = match[1] ?? match[3];
if (!userId) continue;
const fallback = match[2] ?? match[0];
const name = await resolveSlackUserName(userId, workspaceUrl, cache);
resolved = resolved.replaceAll(match[0], name ?? fallback);
}
return resolved;
}
async function resolveSlackAuthor(
author: string | undefined,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string | undefined> {
if (!author) return undefined;
if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author;
return await resolveSlackUserName(author, workspaceUrl, cache) ?? author;
}
function slackMessageUrl(message: Record<string, unknown>, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined {
const direct = message.permalink ?? message.url;
if (typeof direct === 'string' && direct) return direct;
if (!workspaceUrl || !channelId) return undefined;
return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`;
}
import { browserIpcHandlers } from './browser/ipc.js';
/**
@ -373,6 +577,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) {
@ -445,6 +675,13 @@ export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
// for full historical coverage of people you've actually emailed.
warmContactIndex();
warmSentContacts();
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@ -509,6 +746,9 @@ export function setupIpcHandlers() {
'gmail:getAccountEmail': async () => {
return { email: await getAccountEmail() };
},
'gmail:getAccountName': async () => {
return { name: await getAccountName() };
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
@ -522,6 +762,22 @@ export function setupIpcHandlers() {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};
},
'gmail:searchContacts': async (_event, args) => {
const query = args?.query ?? '';
const limit = args?.limit;
const excludeEmails = args?.excludeEmails;
// Primary source: people you've actually sent mail to (Gmail SENT label,
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
// — used only when the SENT index hasn't been populated yet (very first
// launch, before the background sync finishes).
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
if (sent.length > 0) {
return { contacts: sent };
}
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
return { contacts: fallback };
},
'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor);
},
@ -532,12 +788,17 @@ export function setupIpcHandlers() {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
return { success: true };
},
'codeRun:resolvePermission': async (_event, args) => {
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
registry.resolve(args.requestId, args.decision);
return { success: true };
},
'runs:provideHumanInput': async (_event, args) => {
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
return { success: true };
@ -594,6 +855,24 @@ export function setupIpcHandlers() {
'models:test': async (_event, args) => {
return await testModelConnection(args.provider, args.model);
},
'models:listForProvider': async (_event, args) => {
try {
const models = await listModelsForProvider(args.provider);
return { success: true, models };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to list models';
return { success: false, error: message };
}
},
'llm:getDefaultModel': async () => {
return await getDefaultModelAndProvider();
},
'llm:generate': async (_event, args) => {
console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
const result = await generateOneShot(args);
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
return result;
},
'models:saveConfig': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setConfig(args);
@ -639,17 +918,149 @@ export function setupIpcHandlers() {
'codeMode:getConfig': async () => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
const config = await repo.getConfig();
return { enabled: config.enabled };
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
},
'codeMode:setConfig': async (_event, args) => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
await repo.setConfig({ enabled: args.enabled });
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
invalidateCopilotInstructionsCache();
return { success: true };
},
'codeMode:checkAgentStatus': async () => {
return await checkCodeModeAgentStatus();
},
'codeMode:provisionEngine': async (_event, args) => {
// Download + install the agent's engine, streaming progress back to the
// requesting window so Settings can show a live bar. 'check' is instant — skip it.
try {
await ensureEngine(args.agent, {
onProgress: (p) => {
if (p.phase === 'check') return;
_event.sender.send('codeMode:engineProgress', {
agent: args.agent,
phase: p.phase,
receivedBytes: p.receivedBytes,
totalBytes: p.totalBytes,
});
},
});
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
'codeProject:add': async (_event, args) => {
const repo = container.resolve<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) };
},
'codeMode:listModelOptions': async (_event, args) => {
const manager = container.resolve<CodeModeManager>('codeModeManager');
return manager.listModelOptions(args.agent);
},
'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: [] };
}
let files = await codeGit.status(session.cwd);
if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) {
const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch);
const byPath = new Map(branchFiles.map((file) => [file.path, file]));
for (const file of files) {
if (!byPath.has(file.path)) byPath.set(file.path, file);
}
files = [...byPath.values()];
}
return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files };
},
'codeSession:fileDiff': async (_event, args) => {
const session = await requireCodeSession(args.sessionId);
return codeGit.fileDiff(session.cwd, args.path, {
baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null,
});
},
'codeSession:readdir': async (_event, args) => {
const session = await requireCodeSession(args.sessionId);
return { entries: await readProjectDir(session.cwd, args.relPath) };
},
'codeSession:readFile': async (_event, args) => {
const session = await requireCodeSession(args.sessionId);
return readProjectFile(session.cwd, args.relPath);
},
'codeSession:mergeBack': async (_event, args) => {
const service = container.resolve<CodeSessionService>('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 });
@ -669,21 +1080,191 @@ export function setupIpcHandlers() {
'slack:setConfig': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
// Connecting/disconnecting Slack changes the Copilot's routing (native
// `slack` skill vs. Composio), so rebuild its cached instructions.
invalidateCopilotInstructionsCache();
return { success: true };
},
'slack:cliStatus': async () => {
return await getAgentSlackCliStatus();
},
'slack:knowledgeStatus': async () => {
return {
cli: await getAgentSlackCliStatus(),
sources: getSlackKnowledgeSyncStatus(),
};
},
'slack:listWorkspaces': async () => {
try {
const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
const parsed = JSON.parse(stdout);
const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
url: w.workspace_url || '',
name: w.workspace_name || '',
}));
return { workspaces };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
return { workspaces: [], error: message };
const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!result.ok) {
return { workspaces: [], error: result.message, errorKind: result.kind };
}
const workspaces = parseWhoamiWorkspaces(result.data);
return { workspaces };
},
'slack:importDesktopAuth': async () => {
// Pull xoxc token(s) + cookie from the running/installed Slack desktop
// app into agent-slack's credential store, then read back the workspaces.
return await importDesktopAndReadWorkspaces();
},
'slack:quitAndImportDesktop': async () => {
// Windows-only convenience: kill Slack (which locks its Cookies DB) then
// run the normal desktop import in one click.
await quitSlackIfWindows();
return await importDesktopAndReadWorkspaces();
},
'slack:parseCurlAuth': async (_event, args) => {
// Cross-OS fallback to desktop import: the user pastes a "Copy as cURL"
// request from a signed-in Slack web tab; parse-curl reads it from stdin
// and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain.
const curl = (args.curl ?? '').trim();
if (!curl) {
return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const };
}
const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl });
if (!imported.ok) {
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
}
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!whoami.ok) {
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
}
const workspaces = parseWhoamiWorkspaces(whoami.data);
if (workspaces.length === 0) {
return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const };
}
return { ok: true, workspaces };
},
'slack:listChannels': async (_event, args) => {
const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
if (!result.ok) {
return { channels: [], error: result.message };
}
const rawChannels = extractArrayPayload(result.data) as Array<{
id?: string;
name?: string;
is_private?: boolean;
isPrivate?: boolean;
is_member?: boolean;
isMember?: boolean;
}>;
const channels = rawChannels.map((ch) => ({
id: ch.id || ch.name || '',
name: ch.name || ch.id || '',
isPrivate: ch.is_private ?? ch.isPrivate,
isMember: ch.is_member ?? ch.isMember,
})).filter((ch) => ch.id && ch.name);
return { channels };
},
'slack:getRecentMessages': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const config = await repo.getConfig();
if (!config.enabled || config.workspaces.length === 0) {
return { enabled: false, messages: [] };
}
const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
const messages: SlackHomeMessage[] = [];
const userNameCache = new Map<string, string>();
try {
const knowledgeConfig = knowledgeSourcesRepo.getConfig();
const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled);
let channels: SlackHomeChannel[] = (slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => ({
id: scope.id,
name: scope.name ?? scope.id,
workspaceUrl: scope.workspaceUrl,
workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name,
}));
if (channels.length === 0) {
for (const workspace of config.workspaces) {
const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
if (!channelList.ok) {
throw new AgentSlackRunError(channelList.kind, channelList.message);
}
const rawChannels = extractArrayPayload(channelList.data);
for (const raw of rawChannels) {
if (!raw || typeof raw !== 'object') continue;
const channel = raw as Record<string, unknown>;
const id = typeof channel.id === 'string' ? channel.id : undefined;
const name = typeof channel.name === 'string' ? channel.name : id;
const isMember = channel.is_member ?? channel.isMember;
if (!id || !name || isMember === false) continue;
channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name });
}
}
}
channels = channels.slice(0, 8);
for (const channel of channels) {
const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500'];
if (channel.workspaceUrl) {
commandArgs.push('--workspace', channel.workspaceUrl);
}
const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 });
if (!messageList.ok) {
console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`);
continue;
}
const rawMessages = extractArrayPayload(messageList.data);
for (const raw of rawMessages) {
if (!raw || typeof raw !== 'object') continue;
const message = raw as Record<string, unknown>;
const ts = typeof message.ts === 'string' ? message.ts : undefined;
const text = slackMessageText(message);
if (!ts || !text) continue;
const channelId = typeof message.channel_id === 'string'
? message.channel_id
: typeof message.channel === 'string'
? message.channel
: channel.id;
const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
messages.push({
id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
workspaceName: channel.workspaceName,
workspaceUrl: channel.workspaceUrl,
channelId,
channelName: channel.name,
author: resolvedAuthor,
text: resolvedText,
ts,
url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
});
}
}
const rankedIds = await rankSlackHomeMessages(messages, limit);
const byId = new Map(messages.map(message => [message.id, message]));
const rankedMessages = rankedIds
.map(id => byId.get(id))
.filter((message): message is SlackHomeMessage => Boolean(message));
return { enabled: true, messages: rankedMessages };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load Slack messages';
const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined;
return { enabled: true, messages: [], error: message, errorKind };
}
},
'knowledgeSources:getConfig': async () => {
return knowledgeSourcesRepo.getConfig();
},
'knowledgeSources:upsert': async (_event, args) => {
const config = knowledgeSourcesRepo.upsertSource(args);
if (args.provider === 'slack') {
// The Copilot prompt lists the selected Slack channels, so refresh it
// whenever the channel selection changes.
invalidateCopilotInstructionsCache();
triggerSlackKnowledgeSync();
void syncSlackKnowledgeSources().catch(error => {
console.error('[SlackKnowledge] Immediate sync after settings update failed:', error);
});
}
return config;
},
'onboarding:getStatus': async () => {
// Show onboarding if it hasn't been completed yet
@ -800,6 +1381,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);
@ -949,6 +1554,24 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
'voice:ensureMicAccess': async () => {
if (process.platform !== 'darwin') return { granted: true };
const status = systemPreferences.getMediaAccessStatus('microphone');
console.log('[voice] Microphone permission status:', status);
if (status === 'granted') return { granted: true };
// 'not-determined' shows the native TCC prompt and resolves once the
// user responds; 'denied'/'restricted' resolve false without prompting.
// Awaiting this here means the triggering mic click proceeds to
// getUserMedia only after permission is settled — fixing the first
// click silently failing while the prompt was still up.
try {
const granted = await systemPreferences.askForMediaAccess('microphone');
console.log('[voice] Microphone permission after prompt:', granted);
return { granted };
} catch {
return { granted: false };
}
},
// Live-note handlers
'live-note:run': async (_event, args) => {
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
@ -1043,6 +1666,7 @@ export function setupIpcHandlers() {
name: args.name,
instructions: args.instructions,
...(args.triggers ? { triggers: args.triggers } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.model ? { model: args.model } : {}),
...(args.provider ? { provider: args.provider } : {}),
});
@ -1082,6 +1706,13 @@ export function setupIpcHandlers() {
'billing:getInfo': async () => {
return await getBillingInfo();
},
'notifications:getSettings': async () => {
return loadNotificationSettings();
},
'notifications:setSettings': async (_event, args) => {
saveNotificationSettings(args);
return { success: true };
},
// Embedded browser handlers (WebContentsView + navigation)
...browserIpcHandlers,
});

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";
@ -35,12 +37,13 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
@ -53,8 +56,6 @@ import {
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -220,6 +221,7 @@ function createWindow() {
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
webPreferences: {
// IMPORTANT: keep Node out of renderer
nodeIntegration: false,
@ -251,14 +253,34 @@ function createWindow() {
return { action: "deny" };
});
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
win.webContents.on("will-navigate", (event, url) => {
// Handle navigation to external URLs (e.g., clicking a link without target="_blank").
// Returns true when the URL was external and routed to the system browser.
const routeExternalNavigation = (url: string): boolean => {
const isInternal =
url.startsWith("app://") || url.startsWith("http://localhost:5173");
if (!isInternal) {
event.preventDefault();
shell.openExternal(url);
}
if (isInternal) return false;
shell.openExternal(url);
return true;
};
win.webContents.on("will-navigate", (event, url) => {
if (routeExternalNavigation(url)) event.preventDefault();
});
// Subframe navigations (e.g. links clicked inside the sandboxed iframe that
// renders a background-task / workspace `index.html`) fire `will-frame-navigate`,
// not `will-navigate`. Route their external links to the system browser too,
// so HTML reports behave like the markdown viewer. Main-frame navigations are
// already handled by `will-navigate` above — skip them here to avoid double-open.
//
// Scope this to our own HTML viewer frames (identified by their app://workspace
// document origin). Third-party note embeds (YouTube, Figma, Twitter via the
// embed/iframe blocks) load from their own origins — leave their internal
// navigation untouched so the embeds keep working.
win.webContents.on("will-frame-navigate", (event) => {
if (event.isMainFrame) return;
if (!event.frame?.url.startsWith("app://workspace/")) return;
if (routeExternalNavigation(event.url)) event.preventDefault();
});
// Attach the embedded browser pane manager to this window.
@ -289,18 +311,13 @@ app.whenReady().then(async () => {
});
}
// Ensure agent-slack CLI is available
try {
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
} catch {
try {
console.log('agent-slack not found, installing...');
await execAsync('npm install -g agent-slack', { timeout: 60000 });
console.log('agent-slack installed successfully');
} catch (e) {
console.error('Failed to install agent-slack:', e);
}
}
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
// and is resolved per call by the shared executor in @x/core. Availability is
// exposed to the UI via the slack:cliStatus IPC channel; this startup log is
// diagnostics only.
getAgentSlackCliStatus().then((status) => {
console.log('[Slack] agent-slack CLI status:', status);
}).catch(() => { /* probe failures already surface through slack:cliStatus */ });
// Initialize all config files before UI can access them
await initConfigs();
@ -330,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();
@ -416,6 +436,14 @@ app.on("before-quit", () => {
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
try {
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
} catch {
// nothing live to dispose
}
// Kill embedded terminal shells.
disposeAllTerminals();
shutdownLocalSites().catch((error) => {
console.error('[LocalSites] Failed to shut down cleanly:', error);
});

View file

@ -15,7 +15,15 @@ export class ElectronNotificationService implements INotificationService {
return Notification.isSupported();
}
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void {
// Ambient notifications are suppressed while the app is in the
// foreground — the user is already looking at it. A window counts as
// foreground only if it's actually focused (minimized / other-space
// windows are not), so this correctly treats those as background.
if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) {
return;
}
// Build the actions array AND a parallel index → link map.
// macOS shows actions[0] inline (Banner) or all of them (Alert);
// additional ones live behind the chevron menu.

View file

@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
signedInUserId = billing.userId;
analyticsIdentify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
analyticsCapture('user_signed_in', {
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
}

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

@ -160,6 +160,13 @@
border-bottom: 1px solid var(--gm-border);
}
.gmail-topbar-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.gmail-search {
display: flex;
align-items: center;
@ -707,6 +714,132 @@
border-color: var(--gm-border-strong);
}
/* Standalone "new email" composer — centered modal popup */
.gmail-compose-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: rgba(0, 0, 0, 0.32);
}
.gmail-compose-modal,
.gmail-compose-card {
--gm-bg-card: #ffffff;
--gm-bg-input: #f4f4f7;
--gm-bg-elevated: #ffffff;
--gm-bg-pill: #ffffff;
--gm-bg-pill-hover: #f4f4f7;
--gm-text: #27272a;
--gm-text-strong: #09090b;
--gm-text-muted: #71717a;
--gm-text-body: #3f3f46;
--gm-border: #e4e4e7;
--gm-border-strong: #d4d4d8;
--gm-accent: #7c3aed;
--gm-accent-hover: #6d28d9;
--gm-accent-fg: #ffffff;
--gm-icon-hover-bg: #f4f4f7;
--gm-placeholder: #a1a1aa;
}
.gmail-compose-modal {
display: flex;
flex-direction: column;
width: min(840px, 100%);
height: min(720px, calc(100vh - 64px));
max-height: calc(100vh - 64px);
border: 1px solid var(--gm-border-strong);
border-radius: 10px;
overflow: hidden;
background: var(--gm-bg-card);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
}
.gmail-compose-modal-header {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 8px 0 14px;
background: var(--gm-bg-input);
color: var(--gm-text-body);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.gmail-compose-modal-header > span {
flex: 1;
}
.gmail-compose-modal .gmail-compose-editor {
flex: 1;
min-height: 160px;
max-height: none;
padding: 0 14px;
}
.gmail-compose-ai-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--gm-border);
}
.gmail-compose-ai-input {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 6px;
outline: none;
background: var(--gm-bg-input);
color: var(--gm-text);
font: inherit;
font-size: 12px;
}
.gmail-compose-ai-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 10px;
border-bottom: 1px solid var(--gm-border);
}
.gmail-compose-ai-presets button {
height: 24px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 999px;
background: var(--gm-bg-pill);
color: var(--gm-text-muted);
font: inherit;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.gmail-compose-ai-presets button:hover:not(:disabled) {
background: var(--gm-bg-pill-hover);
border-color: var(--gm-accent);
color: var(--gm-accent);
}
.gmail-compose-ai-presets button:disabled,
.gmail-compose-ai-input:disabled {
opacity: 0.5;
cursor: default;
}
.gmail-compose-card {
max-width: 720px;
margin-left: 40px;
@ -800,6 +933,108 @@
gap: 4px;
flex: 1;
min-width: 0;
position: relative;
}
.gmail-recipient-suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 30;
margin: 0;
padding: 6px;
list-style: none;
width: max-content;
min-width: 280px;
max-width: min(440px, 100%);
background: var(--gm-bg-elevated, #ffffff);
border: 1px solid var(--gm-border);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(24, 24, 27, 0.08),
0 12px 32px rgba(24, 24, 27, 0.18);
max-height: 296px;
overflow-y: auto;
overscroll-behavior: contain;
transform-origin: top left;
animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes gmail-recipient-suggestions-in {
from {
opacity: 0;
transform: translateY(-2px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.gmail-recipient-suggestion {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--gm-text);
cursor: pointer;
transition: background-color 80ms linear;
}
.gmail-recipient-suggestion:hover {
background: var(--gm-bg-pill-hover);
}
.gmail-recipient-suggestion.is-active {
background: rgba(99, 142, 255, 0.18);
}
.gmail-recipient-suggestion-avatar {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.gmail-recipient-suggestion-text {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
line-height: 1.25;
}
.gmail-recipient-suggestion-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.gmail-recipient-suggestion-email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11.5px;
color: var(--gm-text-muted);
margin-top: 1px;
}
.gmail-recipient-suggestion-match {
background: transparent;
color: inherit;
font-weight: 700;
padding: 0;
}
.gmail-recipient-chip {
@ -885,7 +1120,10 @@
gap: 2px;
flex: 1;
min-width: 0;
justify-content: center;
justify-content: flex-start;
padding-left: 10px;
margin-left: 2px;
border-left: 1px solid var(--gm-border-strong);
}
.gmail-compose-link-popover {
@ -957,11 +1195,16 @@
transition: background 120ms ease, color 120ms ease;
}
.gmail-compose-tool:hover {
.gmail-compose-tool:hover:not(:disabled) {
background: var(--gm-bg-pill-hover);
color: var(--gm-text);
}
.gmail-compose-tool:disabled {
opacity: 0.4;
cursor: default;
}
.gmail-compose-tool.is-active {
background: var(--gm-bg-pill-hover);
color: var(--gm-accent);
@ -1052,6 +1295,52 @@
pointer-events: none;
}
.gmail-compose-attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 0;
}
.gmail-compose-attachment {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 4px 8px;
border: 1px solid var(--gm-border);
border-radius: 6px;
background: var(--gm-bg-pill);
font-size: 12px;
color: var(--gm-text);
}
.gmail-compose-attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gmail-compose-attachment-size {
color: var(--gm-text-muted);
flex-shrink: 0;
}
.gmail-compose-attachment-remove {
border: none;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 0 0 0 2px;
flex-shrink: 0;
}
.gmail-compose-attachment-remove:hover {
color: var(--gm-text);
}
.gmail-compose-actions {
display: flex;
align-items: center;

View file

@ -5,13 +5,13 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
import { ChatHeader } from './components/chat-header';
import { ChatEmptyState } from './components/chat-empty-state';
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions';
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
@ -29,11 +29,15 @@ import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { KnowledgeView } from '@/components/knowledge-view';
import { CodingRunBlock } from '@/components/coding-run';
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
import { ChatHistoryView } from '@/components/chat-history-view';
import { HomeView } from '@/components/home-view';
import { MeetingsView } from '@/components/meetings-view';
import { CodeView, type ActiveCodeSession } from '@/components/code/code-view';
import { CodeChat } from '@/components/code/code-chat';
import { ResizableRightPane } from '@/components/code/resizable-right-pane';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@ -57,9 +61,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision';
import { TerminalOutput } from '@/components/terminal-output';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
import {
SidebarInset,
SidebarProvider,
@ -117,6 +122,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS'
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
import * as analytics from '@/lib/analytics'
import { useTheme } from '@/contexts/theme-context'
type DirEntry = z.infer<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
@ -165,6 +171,7 @@ function AutoScrollPre({ className, children }: { className?: string; children:
}
const DEFAULT_SIDEBAR_WIDTH = 256
const DEFAULT_CHAT_PANE_WIDTH = 460
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
const graphPalette = [
{ hue: 210, sat: 72, light: 52 },
@ -196,6 +203,7 @@ const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
const HOME_TAB_PATH = '__rowboat_home__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const CODE_TAB_PATH = '__rowboat_code__'
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
@ -370,6 +378,7 @@ const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PAT
const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
const isHomeTabPath = (path: string) => path === HOME_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const isCodeTabPath = (path: string) => path === CODE_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
const normalized = category?.trim().toLowerCase()
@ -620,9 +629,10 @@ type ViewState =
| { type: 'live-notes' }
| { type: 'email' }
| { type: 'workspace'; path?: string }
| { type: 'knowledge-view'; folderPath?: string }
| { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode }
| { type: 'chat-history' }
| { type: 'home' }
| { type: 'code' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@ -630,7 +640,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type === 'file' && b.type === 'file') return a.path === b.path
if (a.type === 'task' && b.type === 'task') return a.name === b.name
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '')
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '')
return true // both graph
}
@ -680,12 +690,19 @@ function parseDeepLink(input: string): ViewState | null {
}
case 'knowledge-view': {
const folderPath = params.get('folderPath')
return { type: 'knowledge-view', folderPath: folderPath ?? undefined }
const mode = params.get('mode')
return {
type: 'knowledge-view',
folderPath: folderPath ?? undefined,
mode: mode === 'graph' || mode === 'basis' || mode === 'files' ? mode : undefined,
}
}
case 'chat-history':
return { type: 'chat-history' }
case 'home':
return { type: 'home' }
case 'code':
return { type: 'code' }
default:
return null
}
@ -773,6 +790,9 @@ function ContentHeader({
}
function App() {
const { chatPanePlacement, chatPaneSize } = useTheme()
const isChatPaneInMiddle = chatPanePlacement === 'middle'
type ShortcutPane = 'left' | 'right'
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
@ -798,13 +818,14 @@ function App() {
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
const [knowledgeViewMode, setKnowledgeViewMode] = useState<KnowledgeViewMode>('graph')
// Folder being browsed inside the knowledge view (null = root overview).
// Lives in ViewState so folder drill-down participates in back/forward history.
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false)
const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge')
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
// Default landing view: Home in the middle with the chat docked on the right.
// Default landing view: Home with the chat docked according to appearance settings.
const [isHomeOpen, setIsHomeOpen] = useState(true)
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
@ -1002,7 +1023,7 @@ function App() {
voice.start()
}, [voice])
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null)
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise<void>) | null>(null)
const pendingVoiceInputRef = useRef(false)
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
@ -1068,7 +1089,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
@ -1193,6 +1214,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)
@ -1206,9 +1244,10 @@ function App() {
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
if (isEmailTabPath(tab.path)) return 'Email'
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
if (isKnowledgeViewTabPath(tab.path)) return 'Brain'
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
if (isHomeTabPath(tab.path)) return 'Home'
if (isCodeTabPath(tab.path)) return 'Code'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -1221,6 +1260,7 @@ function App() {
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
// Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
const [autoPermissionDecisions, setAutoPermissionDecisions] = useState<Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>>(new Map())
useEffect(() => {
chatViewStateByTabRef.current = chatViewStateByTab
@ -1234,6 +1274,7 @@ function App() {
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
allPermissionRequests: new Map(allPermissionRequests),
permissionResponses: new Map(permissionResponses),
autoPermissionDecisions: new Map(autoPermissionDecisions),
}
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
}, [
@ -1244,6 +1285,7 @@ function App() {
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
])
useEffect(() => {
@ -1918,8 +1960,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)
@ -2147,6 +2189,7 @@ function App() {
// Track permission requests and responses from history
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
const permResponseMap = new Map<string, 'approve' | 'deny'>()
const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>()
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
const respondedAskHumanIds = new Set<string>()
@ -2155,6 +2198,8 @@ function App() {
allPermissionRequests.set(event.toolCall.toolCallId, event)
} else if (event.type === 'tool-permission-response') {
permResponseMap.set(event.toolCallId, event.response)
} else if (event.type === 'tool-permission-auto-decision') {
autoPermissionDecisions.set(event.toolCallId, event)
} else if (event.type === 'ask-human-request') {
askHumanRequests.set(event.toolCallId, event)
} else if (event.type === 'ask-human-response') {
@ -2183,10 +2228,20 @@ function App() {
setConversation(items)
setRunId(id)
setMessage('')
// Reconcile composer state with THIS run. Loading a run while another one
// is mid-turn (e.g. binding a code session steals the single chat tab)
// must not leave isProcessing/isStopping pointing at the old run — that
// wedges the composer: stop targets the new run (a no-op) while the old
// run's processing-end arrives flagged as non-active and clears nothing.
setIsProcessing(processingRunIdsRef.current.has(id))
setIsStopping(false)
setStopClickedAt(null)
setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '')
setPendingPermissionRequests(pendingPerms)
setPendingAskHumanRequests(pendingAsks)
setAllPermissionRequests(allPermissionRequests)
setPermissionResponses(permResponseMap)
setAutoPermissionDecisions(autoPermissionDecisions)
// Restore the run's per-chat work directory into the tab it was loaded into.
const tabId = activeChatTabIdRef.current
@ -2252,6 +2307,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)
@ -2306,19 +2366,6 @@ function App() {
status: 'running',
timestamp: Date.now(),
}])
// Detect acpx-driven coding-agent runs so the composer can retroactively
// flip code mode on with the right agent (when the user reached the skill
// via plain prompt rather than the explicit toggle).
if (llmEvent.toolName === 'executeCommand') {
const input = llmEvent.input as { command?: unknown } | undefined
const cmd = typeof input?.command === 'string' ? input.command : ''
const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/)
if (match) {
window.dispatchEvent(new CustomEvent('code-mode-detected', {
detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' },
}))
}
}
} else if (llmEvent.type === 'finish-step') {
const nextUsage = normalizeUsage(llmEvent.usage)
if (nextUsage) {
@ -2416,6 +2463,8 @@ function App() {
...item,
result: event.result as ToolUIPart['output'],
status: 'completed' as const,
// a code_agent_run finished — drop any lingering permission card
pendingCodePermission: null,
}
}
return item
@ -2496,6 +2545,43 @@ function App() {
break
}
case 'code-run-event': {
if (!isActiveRun) return
setConversation(prev => prev.map(item => {
if (isToolCall(item) && item.id === event.toolCallId) {
const existing = item.codeRunEvents ?? []
if (existing.length === 0) {
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
}
return { ...item, codeRunEvents: [...existing, event.event] }
}
return item
}))
break
}
case 'code-run-permission-request': {
if (!isActiveRun) return
setConversation(prev => prev.map(item => {
if (isToolCall(item) && item.id === event.toolCallId) {
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } }
}
return item
}))
break
}
case 'tool-permission-auto-decision': {
if (!isActiveRun) return
setAutoPermissionDecisions(prev => {
const next = new Map(prev)
next.set(event.toolCallId, event)
return next
})
break
}
case 'ask-human-request': {
if (!isActiveRun) return
const key = event.toolCallId
@ -2612,6 +2698,7 @@ function App() {
stagedAttachments: StagedAttachment[] = [],
searchEnabled?: boolean,
codeMode?: 'claude' | 'codex',
permissionMode?: PermissionMode,
) => {
if (isProcessing) return
@ -2651,6 +2738,7 @@ function App() {
const run = await window.ipc.invoke('runs:create', {
agentId,
...(selected ? { model: selected.model, provider: selected.provider } : {}),
permissionMode: permissionMode ?? 'manual',
})
currentRunId = run.id
newRunCreatedAt = run.createdAt
@ -2755,10 +2843,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]
})
@ -2826,6 +2916,26 @@ function App() {
}
}, [runId])
// Answer a mid-run permission request from a code_agent_run coding turn. The
// pending ask lives on the tool call itself, so we optimistically clear it and
// tell main which decision the user picked (keyed by the request id).
const handleCodePermissionResponse = useCallback(async (
toolCallId: string,
requestId: string,
decision: 'allow_once' | 'allow_always' | 'reject',
) => {
setConversation(prev => prev.map(item =>
isToolCall(item) && item.id === toolCallId
? { ...item, pendingCodePermission: null }
: item
))
try {
await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision })
} catch (error) {
console.error('Failed to resolve code permission:', error)
}
}, [])
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
if (!runId) return
try {
@ -2855,6 +2965,7 @@ function App() {
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setAutoPermissionDecisions(new Map())
setSelectedBackgroundTask(null)
setChatViewportAnchor(activeChatTabIdRef.current, null)
setChatViewStateByTab(prev => ({
@ -2881,6 +2992,7 @@ function App() {
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setAutoPermissionDecisions(new Map())
setChatViewportAnchor(tab.id, null)
}
}, [loadRun, setChatViewportAnchor])
@ -2906,6 +3018,7 @@ function App() {
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
setAllPermissionRequests(new Map(cached.allPermissionRequests))
setPermissionResponses(new Map(cached.permissionResponses))
setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions))
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
return true
}, [])
@ -2934,6 +3047,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)
@ -3203,6 +3348,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)
@ -3211,7 +3364,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)
@ -3601,13 +3754,14 @@ function App() {
if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined }
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined, mode: knowledgeViewMode }
if (isChatHistoryOpen) return { type: 'chat-history' }
if (isHomeOpen) return { type: 'home' }
if (isCodeOpen) return { type: 'code' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId])
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@ -3752,6 +3906,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)
@ -3807,6 +3972,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':
@ -3947,6 +4124,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(true)
setKnowledgeViewMode(view.mode ?? (view.folderPath ? 'files' : 'graph'))
setKnowledgeViewFolderPath(view.folderPath ?? null)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
@ -3987,6 +4165,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)
@ -4015,7 +4204,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
@ -4162,10 +4351,9 @@ function App() {
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
}, [])
const handleBaseSave = useCallback(async (name: string | null) => {
if (!selectedPath) return
const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH
const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG
const handleBaseSave = useCallback(async (path: string, name: string | null) => {
const isDefault = path === BASES_DEFAULT_TAB_PATH
const config = baseConfigByPath[path] ?? DEFAULT_BASE_CONFIG
if (isDefault && name) {
// Save as new base file
@ -4189,14 +4377,14 @@ function App() {
// Save in place
try {
await window.ipc.invoke('workspace:writeFile', {
path: selectedPath,
path,
data: JSON.stringify(config, null, 2),
})
} catch (err) {
console.error('Failed to save base:', err)
}
}
}, [selectedPath, baseConfigByPath, loadDirectory, navigateToView])
}, [baseConfigByPath, loadDirectory, navigateToView])
// External search set by app-navigation tool (passed to BasesView)
const [externalBaseSearch, setExternalBaseSearch] = useState<string | undefined>(undefined)
@ -4350,7 +4538,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') {
@ -5064,8 +5252,10 @@ function App() {
},
}), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile])
const isBrainGraphOpen = isKnowledgeViewOpen && knowledgeViewMode === 'graph'
useEffect(() => {
if (!isGraphOpen) return
if (!isGraphOpen && !isBrainGraphOpen) return
let cancelled = false
const buildGraph = async () => {
@ -5180,9 +5370,13 @@ function App() {
return () => {
cancelled = true
}
}, [isGraphOpen, knowledgeFilePaths])
}, [isGraphOpen, isBrainGraphOpen, knowledgeFilePaths])
const renderConversationItem = (item: ConversationItem, tabId: string) => {
const renderConversationItem = (
item: ConversationItem,
tabId: string,
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
@ -5240,6 +5434,21 @@ function App() {
}
if (isToolCall(item)) {
if (item.name === 'code_agent_run') {
return (
<CodingRunBlock
key={item.id}
item={item}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
onPermissionDecision={(decision) => {
if (item.pendingCodePermission) {
handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision)
}
}}
/>
)
}
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
@ -5280,6 +5489,7 @@ function App() {
key={item.id}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
autoPermissionDetail={options?.autoPermissionDetail}
>
<ToolHeader
title={toolTitle}
@ -5322,6 +5532,7 @@ function App() {
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
}), [
runId,
conversation,
@ -5329,6 +5540,7 @@ function App() {
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
])
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
@ -5338,9 +5550,20 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const nonChatPaneStyle = React.useMemo<React.CSSProperties>(() => {
const style: React.CSSProperties = { maxWidth: insetMaxWidth }
if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style
if (chatPaneSize === 'chat-equal') {
return { ...style, width: 0, flex: '1 1 0' }
}
if (chatPaneSize === 'chat-bigger') {
return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' }
}
return style
}, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized])
// Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's
// binding immediately (no flex jump), then animate to 0. Expanding goes back to 100%
// — its non-binding range lands at the end of the range, where it isn't visible.
@ -5395,16 +5618,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}
@ -5417,10 +5643,11 @@ function App() {
<SidebarInset
className={cn(
"overflow-hidden! min-h-0 min-w-0",
isRightPaneContext && isChatPaneInMiddle && "order-3",
insetAnimateMaxWidth && "transition-[max-width] duration-200 ease-linear",
shouldCollapseLeftPane && "pointer-events-none select-none"
)}
style={{ maxWidth: insetMaxWidth }}
style={nonChatPaneStyle}
aria-hidden={shouldCollapseLeftPane}
onMouseDownCapture={() => setActiveShortcutPane('left')}
onFocusCapture={() => setActiveShortcutPane('left')}
@ -5433,7 +5660,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 ?? ''}
@ -5441,7 +5668,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
@ -5506,7 +5733,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
@ -5532,7 +5759,11 @@ function App() {
: (viewOpen && !isChatSidebarOpen)
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
: (viewOpen && isChatSidebarOpen && !isRightPaneMaximized)
? { onClick: () => setIsChatSidebarOpen(false), icon: <ArrowRight className="size-5" />, label: 'Expand pane' }
? {
onClick: () => setIsChatSidebarOpen(false),
icon: isChatPaneInMiddle ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />,
label: 'Expand pane'
}
: null
return (
<Tooltip>
@ -5596,6 +5827,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
@ -5654,12 +5893,44 @@ function App() {
revealInFileManager: knowledgeActions.revealInFileManager,
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
}}
mode={knowledgeViewMode}
onModeChange={setKnowledgeViewMode}
graphContent={(
<GraphView
nodes={graphData.nodes}
edges={graphData.edges}
isLoading={false}
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
onSelectNode={(path) => {
navigateToFile(path)
}}
/>
)}
basisContent={(
<BasesView
tree={tree}
onSelectNote={(path) => navigateToFile(path)}
config={baseConfigByPath[BASES_DEFAULT_TAB_PATH] ?? DEFAULT_BASE_CONFIG}
onConfigChange={(cfg) => handleBaseConfigChange(BASES_DEFAULT_TAB_PATH, cfg)}
isDefaultBase
onSave={(name) => void handleBaseSave(BASES_DEFAULT_TAB_PATH, name)}
externalSearch={externalBaseSearch}
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
actions={{
rename: knowledgeActions.rename,
remove: knowledgeActions.remove,
copyPath: knowledgeActions.copyPath,
revealInFileManager: knowledgeActions.revealInFileManager,
}}
/>
)}
folderPath={knowledgeViewFolderPath}
onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }}
onNavigateFolder={(path) => {
setKnowledgeViewMode('files')
void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined, mode: 'files' })
}}
onOpenNote={(path) => navigateToFile(path)}
onOpenGraph={() => knowledgeActions.openGraph()}
onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }}
onOpenBases={() => knowledgeActions.openBases()}
onVoiceNoteCreated={handleVoiceNoteCreated}
/>
</div>
@ -5690,7 +5961,7 @@ function App() {
config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG}
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
onSave={(name) => void handleBaseSave(name)}
onSave={(name) => void handleBaseSave(selectedPath, name)}
externalSearch={externalBaseSearch}
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
actions={{
@ -5929,7 +6200,7 @@ function App() {
<>
{groupConversationItems(
tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id)
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
).map(item => {
if (isToolGroup(item)) {
return (
@ -5941,41 +6212,43 @@ function App() {
/>
)
}
const rendered = renderConversationItem(item, tab.id)
const autoDecision = isToolCall(item)
? tabState.autoPermissionDecisions.get(item.id)
: undefined
const rendered = renderConversationItem(
item,
tab.id,
autoDecision?.decision === 'allow'
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
: undefined,
)
if (isToolCall(item)) {
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) {
if (deniedAutoDecision || permRequest) {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
onSwitchAgent={async (newAgent) => {
const runIdForSwitch = tab.runId
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
window.dispatchEvent(new CustomEvent('code-mode-detected', {
detail: { runId: runIdForSwitch, agent: newAgent },
}))
if (runIdForSwitch) {
try {
await window.ipc.invoke('runs:createMessage', {
runId: runIdForSwitch,
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
codeMode: newAgent,
})
} catch (err) {
console.error('Failed to send swap-agent follow-up', err)
}
}
}}
isProcessing={isActive && isProcessing}
response={response}
/>
{deniedAutoDecision && (
<AutoPermissionDecision
toolCall={deniedAutoDecision.toolCall}
permission={deniedAutoDecision.permission}
decision={deniedAutoDecision.decision}
reason={deniedAutoDecision.reason}
/>
)}
{permRequest && (
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
)}
{rendered}
</React.Fragment>
)
@ -6044,6 +6317,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) => {
@ -6058,6 +6332,7 @@ function App() {
isRecording={isActive && isRecording}
recordingText={isActive ? voice.interimText : undefined}
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
audioLevelsRef={voice.audioLevelsRef}
onStartRecording={isActive ? handleStartRecording : undefined}
onSubmitRecording={isActive ? handleSubmitRecording : undefined}
onCancelRecording={isActive ? handleCancelRecording : undefined}
@ -6078,10 +6353,27 @@ function App() {
)}
</SidebarInset>
{/* Chat sidebar - 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
defaultWidth={460}
placement={chatPanePlacement}
paneSize={chatPaneSize}
className={isChatPaneInMiddle ? "order-2" : undefined}
defaultWidth={DEFAULT_CHAT_PANE_WIDTH}
isOpen={isChatSidebarOpen}
isMaximized={isRightPaneMaximized}
chatTabs={chatTabs}
@ -6125,9 +6417,20 @@ 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}
autoPermissionDecisions={autoPermissionDecisions}
onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse}
isToolOpenForTab={isToolOpenForTab}
@ -6138,6 +6441,7 @@ function App() {
isRecording={isRecording}
recordingText={voice.interimText}
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
audioLevelsRef={voice.audioLevelsRef}
onStartRecording={handleStartRecording}
onSubmitRecording={handleSubmitRecording}
onCancelRecording={handleCancelRecording}

View file

@ -0,0 +1,100 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
export type AutoPermissionDecisionProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>;
decision: "allow" | "deny";
reason: string;
permission?: z.infer<typeof ToolPermissionMetadata>;
};
const fileActionLabels: Record<string, string> = {
read: "Read file",
list: "List folder",
search: "Search files",
write: "Write files",
delete: "Delete path",
};
export function AutoPermissionDecision({
className,
toolCall,
decision,
reason,
permission,
...props
}: AutoPermissionDecisionProps) {
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const filePermission = permission?.kind === "file" ? permission : null;
const allowed = decision === "allow";
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
allowed
? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30"
: "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20",
className,
)}
{...props}
>
<div className="space-y-3 p-4">
<div className="flex items-start gap-3">
{allowed ? (
<CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" />
) : (
<ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
{allowed ? "Auto Allowed" : "Auto Denied"}
</h3>
<Badge variant="secondary" className="bg-secondary text-foreground">
<Terminal className="mr-1 size-3" />
{toolCall.toolName}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{reason}</p>
</div>
</div>
{command && (
<div className="rounded-md border bg-background/50 p-3">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre>
</div>
)}
{filePermission && (
<div className="space-y-3 rounded-md border bg-background/50 p-3">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p>
<p className="text-xs font-medium text-foreground">
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
</p>
</div>
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Path{filePermission.paths.length === 1 ? "" : "s"}
</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">
{filePermission.paths.join("\n")}
</pre>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -9,7 +8,7 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react";
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
@ -21,7 +20,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onApproveSession?: () => void;
onApproveAlways?: () => void;
onDeny?: () => void;
onSwitchAgent?: (newAgent: 'claude' | 'codex') => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
permission?: z.infer<typeof ToolPermissionMetadata>;
@ -42,7 +40,6 @@ export const PermissionRequest = ({
onApproveSession,
onApproveAlways,
onDeny,
onSwitchAgent,
isProcessing = false,
response = null,
permission,
@ -56,17 +53,6 @@ export const PermissionRequest = ({
: null;
const filePermission = permission?.kind === "file" ? permission : null;
// Detect acpx coding-agent invocations so we can show the agent identity and
// offer a one-click swap-and-retry.
const acpxAgent: 'claude' | 'codex' | null = (() => {
if (!command) return null;
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/);
return match ? (match[1] as 'claude' | 'codex') : null;
})();
const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null;
const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null;
const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null;
const isResponded = response !== null;
const isApproved = response === 'approve';
@ -104,15 +90,6 @@ export const PermissionRequest = ({
</h3>
<p className="text-sm text-muted-foreground mt-1">
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
{agentDisplay && (
<Badge
variant="secondary"
className="ml-2 align-middle bg-secondary text-foreground"
>
<Terminal className="size-3 mr-1" />
{agentDisplay}
</Badge>
)}
</p>
</div>
{isResponded && (
@ -220,18 +197,6 @@ export const PermissionRequest = ({
<XIcon className="size-4" />
Deny
</Button>
{otherAgent && otherDisplay && onSwitchAgent && (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitchAgent(otherAgent)}
disabled={isProcessing}
className="flex-1"
>
<RefreshCwIcon className="size-4" />
Use {otherDisplay} instead
</Button>
)}
</div>
)}
</div>

View file

@ -5,12 +5,18 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai";
import {
ChevronDownIcon,
CircleCheck,
LoaderIcon,
ShieldCheckIcon,
XCircleIcon,
} from "lucide-react";
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
@ -45,17 +51,51 @@ const ToolCode = ({
</pre>
);
export type ToolProps = ComponentProps<typeof Collapsible>;
export type ToolAutoPermissionDetail = {
decision: "allow";
reason: string;
};
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn(
"not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className
)}
{...props}
/>
);
export type ToolProps = ComponentProps<typeof Collapsible> & {
autoPermissionDetail?: ToolAutoPermissionDetail;
};
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
const toolCard = (
<Collapsible
className={cn(
autoPermissionDetail
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className
)}
{...props}
>
{children}
</Collapsible>
);
if (!autoPermissionDetail) return toolCard;
return (
<div className="not-prose mb-4 w-full">
{toolCard}
<div className="mt-1 flex justify-end px-3">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
Auto-approved
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" className="max-w-sm">
{autoPermissionDetail.reason}
</TooltipContent>
</Tooltip>
</div>
</div>
);
};
export type ToolHeaderProps = {
title?: string;

View file

@ -4,6 +4,7 @@ import {
ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle,
Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight,
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
Code2, FolderOpen, LayoutTemplate,
} from 'lucide-react'
import type { z } from 'zod'
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
@ -19,6 +20,7 @@ import type { ConversationItem } from '@/lib/chat-conversation'
import { runLogToConversation } from '@/lib/run-to-conversation'
import { CompactConversation } from '@/components/compact-conversation'
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
import { HtmlFileViewer } from '@/components/html-file-viewer'
// ---------------------------------------------------------------------------
// Trigger helpers (inlined; extract to shared <TriggersEditor> as a follow-up)
@ -270,7 +272,16 @@ function TriggersEditor({
// New Task dialog
// ---------------------------------------------------------------------------
type DialogMode = 'describe' | 'manual'
type DialogMode = 'describe' | 'manual' | 'templates' | 'coding'
// Prefills for the "Coding from meetings" preset.
const CODING_PRESET = {
name: 'Implement coding items from meetings',
instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team.
Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`,
eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`,
}
function NewTaskDialog({
open,
@ -294,6 +305,9 @@ function NewTaskDialog({
const [name, setName] = useState('')
const [instructions, setInstructions] = useState('')
const [triggers, setTriggers] = useState<Triggers | undefined>(undefined)
const [projectId, setProjectId] = useState<string | undefined>(undefined)
const [projectName, setProjectName] = useState<string | undefined>(undefined)
const [addingProject, setAddingProject] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
@ -303,11 +317,64 @@ function NewTaskDialog({
setName('')
setInstructions('')
setTriggers(undefined)
setProjectId(undefined)
setProjectName(undefined)
}
}, [open, copilotEnabled])
// Switch into the coding preset: prefill name/instructions/trigger once.
const enterCodingMode = () => {
setMode('coding')
setName(CODING_PRESET.name)
setInstructions(CODING_PRESET.instructions)
setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria })
}
const pickRepo = async () => {
setAddingProject(true)
try {
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' })
const dir = res.path
if (!dir) return
const added = await window.ipc.invoke('codeProject:add', { path: dir })
if (!added.git?.isGitRepo) {
toast('That folder is not a git repository — coding tasks need one.', 'error')
return
}
setProjectId(added.project.id)
setProjectName(added.project.name)
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
} finally {
setAddingProject(false)
}
}
const canSubmitDescribe = description.trim().length > 0 && !submitting
const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting
const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting
const submitCoding = async () => {
if (!canSubmitCoding) return
setSubmitting(true)
try {
const result = await window.ipc.invoke('bg-task:create', {
name: name.trim(),
instructions: instructions.trim(),
...(triggers ? { triggers } : {}),
...(projectId ? { projectId } : {}),
})
if (result.success && result.slug) {
onCreated(result.slug)
} else {
toast(result.error ?? 'Failed to create task', 'error')
}
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
} finally {
setSubmitting(false)
}
}
const submitDescribe = () => {
if (!canSubmitDescribe || !onCreateWithCopilot) return
@ -358,7 +425,116 @@ function NewTaskDialog({
</button>
</div>
{mode === 'describe' ? (
{(mode === 'describe' || mode === 'manual') && (
<button
type="button"
onClick={() => setMode('templates')}
className="mb-4 flex w-full items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-left text-[12px] hover:border-solid hover:bg-accent"
>
<span className="flex items-center gap-2">
<LayoutTemplate className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">View available templates</span>
</span>
<ChevronRight className="size-4 text-muted-foreground" />
</button>
)}
{mode === 'templates' ? (
<>
<div className="space-y-2">
{[
{
id: 'coding-from-meetings',
title: 'Coding from meetings',
description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.",
icon: Code2,
onSelect: enterCodingMode,
},
].map(preset => (
<button
key={preset.id}
type="button"
onClick={preset.onSelect}
className="flex w-full items-start gap-2.5 rounded-md border bg-muted/40 px-3 py-2.5 text-left hover:border-foreground/30 hover:bg-accent"
>
<preset.icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-[12.5px] font-medium">{preset.title}</span>
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">{preset.description}</span>
</span>
</button>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Back
</button>
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
</div>
</>
) : mode === 'coding' ? (
<>
<div className="space-y-4">
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Repository</label>
{projectName ? (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2">
<span className="flex items-center gap-2 text-[13px]">
<FolderOpen className="size-4 text-muted-foreground" />
<span className="font-medium">{projectName}</span>
</span>
<button type="button" onClick={pickRepo} className="text-[11px] text-muted-foreground hover:text-foreground" disabled={addingProject}>Change</button>
</div>
) : (
<Button variant="outline" size="sm" onClick={pickRepo} disabled={addingProject}>
{addingProject ? <Loader2 className="mr-1 size-3 animate-spin" /> : <FolderOpen className="mr-1 size-3" />}
Choose a git repository
</Button>
)}
<p className="mt-1 text-[11px] text-muted-foreground">
Code changes run full-auto in an isolated git worktree your working checkout is never touched.
</p>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Name</label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Instructions</label>
<Textarea value={instructions} onChange={e => setInstructions(e.target.value)} rows={6} className="text-[12.5px] leading-relaxed" />
</div>
<div>
<label className="mb-2 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</label>
<TriggersEditor value={triggers} onChange={setTriggers} />
<p className="mt-2 text-[11px] text-muted-foreground">
Prefilled to fire when a meeting's notes become available. Adjust if you want.
</p>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Back
</button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Cancel</Button>
<Button size="sm" onClick={submitCoding} disabled={!canSubmitCoding}>
{submitting && <Loader2 className="mr-1 size-3 animate-spin" />}
Create
</Button>
</div>
</div>
</>
) : mode === 'describe' ? (
<>
<Textarea
value={description}
@ -502,15 +678,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
}
// ---------------------------------------------------------------------------
// Output pane — index.md (main pane content)
// Output pane — index.html (preferred) or index.md (main pane content)
//
// Renders the task's `index.md` like a note: max-width 720px centered, same
// typography (~16px, 1.5 line-height, generous padding) as the note editor's
// ProseMirror rule in `editor.css`. No chrome above the body — just the
// markdown, with a small floating Source ⇄ Rendered toggle in the top-right.
// A task's agent-owned artifact is either:
// - `index.html` — a self-contained, styled web page. Rendered full-bleed in
// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace`
// protocol) so CSS, layout, and scripts render faithfully. Preferred when
// present and non-empty.
// - `index.md` — a note. Rendered like the note editor: max-width 720px
// centered, same typography as `editor.css`, via `RichMarkdownViewer`.
//
// In both cases a small floating Source ⇄ Rendered toggle in the top-right
// swaps the rendered view for the raw file source.
// ---------------------------------------------------------------------------
function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) {
const [mode, setMode] = useState<'md' | 'html'>('md')
const [body, setBody] = useState<string>('')
const [loading, setLoading] = useState(true)
const [viewSource, setViewSource] = useState(false)
@ -519,21 +702,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
let cancelled = false
setLoading(true)
void (async () => {
// Prefer index.html when it exists and has content; otherwise fall
// back to index.md (the default seeded artifact).
try {
const result = await window.ipc.invoke('workspace:readFile', {
const html = await window.ipc.invoke('workspace:readFile', {
path: `bg-tasks/${slug}/index.html`,
})
if (html.data.trim()) {
if (!cancelled) { setMode('html'); setBody(html.data) }
return
}
} catch {
// No index.html — fall through to markdown.
}
try {
const md = await window.ipc.invoke('workspace:readFile', {
path: `bg-tasks/${slug}/index.md`,
})
if (!cancelled) setBody(result.data)
if (!cancelled) { setMode('md'); setBody(md.data) }
} catch {
if (!cancelled) setBody('')
} finally {
if (!cancelled) setLoading(false)
if (!cancelled) { setMode('md'); setBody('') }
}
})()
})().finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [slug, refreshKey])
const isEmpty = !body.trim() || body.trim() === `# ${taskName}`
const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`)
const showHtml = mode === 'html' && !viewSource
return (
<div className="relative flex-1 overflow-hidden bg-background">
@ -542,29 +737,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
type="button"
onClick={() => setViewSource(v => !v)}
className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground"
aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'}
aria-label={viewSource ? 'Show rendered output' : 'Show source'}
>
{viewSource ? 'Rendered' : 'Source'}
</button>
)}
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-[720px] px-16 py-8">
{loading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
) : isEmpty ? (
<p className="text-sm italic text-muted-foreground">
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
</p>
) : viewSource ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
) : (
<RichMarkdownViewer content={body} />
)}
{showHtml ? (
// Full-bleed: the iframe fills the pane and scrolls internally.
// Remount on refreshKey so a re-run's updated index.html reloads.
<HtmlFileViewer key={`${slug}-${refreshKey}`} path={`bg-tasks/${slug}/index.html`} />
) : (
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-[720px] px-16 py-8">
{loading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
) : isEmpty ? (
<p className="text-sm italic text-muted-foreground">
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
</p>
) : viewSource ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
) : (
<RichMarkdownViewer content={body} />
)}
</div>
</div>
</div>
)}
</div>
)
}
@ -1237,6 +1438,8 @@ function TaskDetail({
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [outputRefreshKey, setOutputRefreshKey] = useState(0)
// Whether we've already chosen the initial sidebar state for this task.
const sidebarInitialized = useRef(false)
const agentStatus = useBackgroundTaskAgentStatus()
const liveStatus = agentStatus.get(slug)
@ -1252,6 +1455,23 @@ function TaskDetail({
if (result.success && result.task) {
setTask(result.task)
setDraft(result.task)
// On first open, collapse the details sidebar when the agent
// already has output — let the user read it without chrome.
// Resolved before `loading` clears so the sidebar never flashes.
if (!sidebarInitialized.current) {
sidebarInitialized.current = true
try {
const out = await window.ipc.invoke('workspace:readFile', {
path: `bg-tasks/${slug}/index.md`,
})
const body = (out.data ?? '').trim()
if (body && body !== `# ${result.task.name}`) {
setSidebarOpen(false)
}
} catch {
// No output file yet — keep the sidebar open.
}
}
}
} finally {
setLoading(false)

View file

@ -2,6 +2,7 @@ import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'luc
import { cn } from '@/lib/utils'
import { formatRelativeTime } from '@/lib/relative-time'
import { ToolConnectionsCard } from '@/components/tool-connections-card'
export interface ChatEmptyStateRun {
id: string
@ -101,6 +102,8 @@ export function ChatEmptyState({
))}
</div>
</div>
<ToolConnectionsCard />
</div>
)
}

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

@ -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'
@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent }
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
import { TerminalOutput } from '@/components/terminal-output'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
@ -36,10 +37,11 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
import { defaultRemarkPlugins } from 'streamdown'
import remarkBreaks from 'remark-breaks'
import { type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { useSidebar } from '@/components/ui/sidebar'
import { wikiLabel } from '@/lib/wiki-links'
import type { ChatPaneSize } from '@/contexts/theme-context'
import {
type ChatViewportAnchorState,
type ChatTabViewState,
@ -124,6 +126,9 @@ interface ChatSidebarProps {
defaultWidth?: number
isOpen?: boolean
isMaximized?: boolean
placement?: 'middle' | 'right'
paneSize?: ChatPaneSize
className?: string
chatTabs: ChatTab[]
activeChatTabId: string
getChatTabTitle: (tab: ChatTab) => string
@ -139,7 +144,7 @@ interface ChatSidebarProps {
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
@ -150,10 +155,18 @@ 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']
permissionResponses?: ChatTabViewState['permissionResponses']
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
@ -165,6 +178,7 @@ interface ChatSidebarProps {
isRecording?: boolean
recordingText?: string
recordingState?: 'connecting' | 'listening'
audioLevelsRef?: React.MutableRefObject<number[]>
onStartRecording?: () => void
onSubmitRecording?: () => void
onCancelRecording?: () => void
@ -181,6 +195,9 @@ export function ChatSidebar({
defaultWidth = DEFAULT_WIDTH,
isOpen = true,
isMaximized = false,
placement = 'right',
paneSize = 'chat-smaller',
className,
chatTabs,
activeChatTabId,
getChatTabTitle,
@ -207,10 +224,13 @@ export function ChatSidebar({
onDraftChangeForTab,
onSelectedModelChangeForTab,
workDirByTab = {},
codeSessionLocks = {},
pinnedToCodeSession = null,
onWorkDirChangeForTab,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
permissionResponses = new Map(),
autoPermissionDecisions = new Map(),
onPermissionResponse,
onAskHumanResponse,
isToolOpenForTab,
@ -221,6 +241,7 @@ export function ChatSidebar({
isRecording,
recordingText,
recordingState,
audioLevelsRef,
onStartRecording,
onSubmitRecording,
onCancelRecording,
@ -243,6 +264,8 @@ export function ChatSidebar({
const startWidthRef = useRef(0)
const prevIsMaximizedRef = useRef(isMaximized)
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
const isMiddlePlacement = placement === 'middle'
const isResizable = paneSize === 'chat-smaller'
const getMaxAllowedWidth = useCallback(() => {
if (typeof window === 'undefined') return MAX_WIDTH
@ -303,7 +326,9 @@ export function ChatSidebar({
setIsResizing(true)
const handleMouseMove = (event: MouseEvent) => {
const delta = startXRef.current - event.clientX
const delta = isMiddlePlacement
? event.clientX - startXRef.current
: startXRef.current - event.clientX
const maxAllowedWidth = getMaxAllowedWidth()
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
}
@ -316,7 +341,7 @@ export function ChatSidebar({
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [width, getMaxAllowedWidth])
}, [width, getMaxAllowedWidth, isMiddlePlacement])
const activeTabState = useMemo<ChatTabViewState>(() => ({
runId: runId ?? null,
@ -325,6 +350,7 @@ export function ChatSidebar({
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
}), [
runId,
conversation,
@ -332,6 +358,7 @@ export function ChatSidebar({
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
])
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getTabState = useCallback((tabId: string): ChatTabViewState => {
@ -358,7 +385,11 @@ export function ChatSidebar({
}
}, [activeRunId])
const renderConversationItem = (item: ConversationItem, tabId: string) => {
const renderConversationItem = (
item: ConversationItem,
tabId: string,
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
@ -451,6 +482,7 @@ export function ChatSidebar({
key={item.id}
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
autoPermissionDetail={options?.autoPermissionDetail}
>
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
@ -491,8 +523,11 @@ export function ChatSidebar({
// not add extra width to the right and overflow the app viewport.
return { width: 0, flex: '1 1 auto' }
}
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
return { width: 0, flex: '1 1 0' }
}
return { width, flex: '0 0 auto' }
}, [isOpen, isMaximized, width])
}, [isOpen, isMaximized, paneSize, width])
return (
<div
@ -501,16 +536,19 @@ export function ChatSidebar({
onMouseDownCapture={onActivate}
onFocusCapture={onActivate}
className={cn(
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
'relative flex min-w-0 flex-col overflow-hidden bg-background',
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
className
)}
style={paneStyle}
>
{!isMaximized && (
{!isMaximized && isResizable && (
<div
onMouseDown={handleMouseDown}
className={cn(
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
'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'
@ -528,17 +566,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>
@ -577,7 +632,9 @@ export function ChatSidebar({
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
>
{isMaximized ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />}
{isMaximized
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
@ -617,16 +674,18 @@ 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}
/>
) : (
<>
{groupConversationItems(
tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id)
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
).map((item) => {
if (isToolGroup(item)) {
return (
@ -638,22 +697,43 @@ export function ChatSidebar({
/>
)
}
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item) && onPermissionResponse) {
const autoDecision = isToolCall(item)
? tabState.autoPermissionDecisions.get(item.id)
: undefined
const rendered = renderConversationItem(
item,
tab.id,
autoDecision?.decision === 'allow'
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
: undefined,
)
if (isToolCall(item)) {
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) {
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
{deniedAutoDecision && (
<AutoPermissionDecision
toolCall={deniedAutoDecision.toolCall}
permission={deniedAutoDecision.permission}
decision={deniedAutoDecision.decision}
reason={deniedAutoDecision.reason}
/>
)}
{permRequest && onPermissionResponse && (
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
)}
{rendered}
</React.Fragment>
)
@ -729,9 +809,11 @@ 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}
audioLevelsRef={audioLevelsRef}
onStartRecording={isActive ? onStartRecording : undefined}
onSubmitRecording={isActive ? onSubmitRecording : undefined}
onCancelRecording={isActive ? onCancelRecording : 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,28 @@
import type { CodingAgent } from '@x/shared/src/code-mode.js'
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
// Model + effort choices for a coding agent, discovered live from the engine
// (the same list `/model` shows) via the main process, which caches per agent.
// We memoize the in-flight/resolved promise per agent here too so reopening the
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
// just falls back to the engine default.
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
let pending = cache.get(agent)
if (!pending) {
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
cache.set(agent, pending)
}
return pending
}
// Always offer a Default fallback even before options load (or if discovery fails).
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
}
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
}

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,402 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
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
// Remember which session was open so leaving the Code section (which unmounts
// this view) and coming back restores the selection — and with it the chat
// output in the right pane — instead of dropping back to the empty state.
const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session'
function readStoredSelectedSessionId(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null
}
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',
}
const POLICY_HEADER_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask',
'auto-approve-reads': 'Auto reads',
yolo: 'Auto all',
}
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>(readStoredSelectedSessionId)
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])
useEffect(() => {
if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId)
else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY)
}, [selectedSessionId])
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
// Live model/effort choices for the selected session's agent, for the header
// pickers. Discovered from the engine and cached, so this is cheap to re-run.
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
const selectedAgent = selectedSession?.agent
useEffect(() => {
if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return }
let cancelled = false
void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) })
return () => { cancelled = true }
}, [selectedAgent])
// 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'; agentModel?: string; agentEffort?: string }) => {
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 flex-wrap items-start gap-x-3 gap-y-2 border-b px-4 py-2.5">
<div className="min-w-64 flex-[1_1_360px]">
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="shrink-0 whitespace-nowrap">{AGENT_LABEL[selectedSession.agent]}</span>
<span className="shrink-0 text-muted-foreground/50">·</span>
<span className="min-w-0 max-w-full flex-1 truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
<span className="flex min-w-0 max-w-72 shrink items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
<GitBranch className="size-3" />
<span className="truncate">{selectedSession.worktree.branch}</span>
</span>
)}
</div>
</div>
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Coding agent model"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
{withDefault(modelOpts.models).map((m) => (
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
{m.label}
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{modelOpts.efforts.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Reasoning effort"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.efforts, selectedSession.agentEffort)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{withDefault(modelOpts.efforts).map((e) => (
<DropdownMenuItem key={e.value} onClick={() => void handleUpdateSession({ agentEffort: e.value })}>
{e.label}
{(selectedSession.agentEffort ?? 'default') === e.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title={POLICY_LABEL[selectedSession.policy]}
>
<span className="whitespace-nowrap">{POLICY_HEADER_LABEL[selectedSession.policy]}</span>
<ChevronDown className="size-3" />
</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" />
<span className="whitespace-nowrap">Rowboat drives</span>
<Switch
checked={selectedSession.mode === 'rowboat'}
disabled={busy}
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
/>
</label>
</div>
</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,372 @@
import { useEffect, useState } from 'react'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
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')
// The coding agent's own model + reasoning effort. 'default' leaves the
// engine default. Choices are discovered live per agent (see effect below).
const [agentModel, setAgentModel] = useState('default')
const [agentEffort, setAgentEffort] = useState('default')
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
const git = projectRow?.git
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
useEffect(() => {
if (!open) return
setTitle('')
setCreating(false)
setIsolation('in-repo')
setMode('rowboat')
setModelKey('default')
setAgentModel('default')
setAgentEffort('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])
// Model/effort choices are per-agent (and the saved value from one agent is
// meaningless for the other), so reset to defaults and (re)load the live list
// whenever the agent changes.
useEffect(() => {
setAgentModel('default')
setAgentEffort('default')
setModelOpts({ models: [], efforts: [] })
let cancelled = false
void fetchCodeAgentOptions(agent).then((opts) => { if (!cancelled) setModelOpts(opts) })
return () => { cancelled = true }
}, [agent])
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 } : {}),
...(agentModel !== 'default' ? { agentModel } : {}),
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
})
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 coding agent's own model + reasoning effort, discovered live
from the engine and applied to the ACP session each turn (so they
stay editable from the session header later). Effort is a separate
axis only for Claude; Codex folds it into the model id. */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={agentModel} onValueChange={setAgentModel}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.models).map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{modelOpts.efforts.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Effort</label>
<Select value={agentEffort} onValueChange={setAgentEffort}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.efforts).map((e) => (
<SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</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

@ -0,0 +1,272 @@
import { useMemo, useState } from 'react'
import {
CheckCircle2,
Circle,
CircleDot,
Eye,
FileText,
Loader,
Pencil,
Search,
ShieldQuestion,
Terminal,
Trash2,
Wrench,
} from 'lucide-react'
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
// ── Timeline reduction ──────────────────────────────────────────────
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
// folding tool_call + tool_call_update (by id) and the latest plan in place.
type TextRow = { kind: 'text'; id: string; text: string }
type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] }
type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] }
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
type Row = TextRow | ToolRow | PlanRow | PermRow
export function reduceEvents(events: CodeRunEvent[]): Row[] {
const rows: Row[] = []
const toolIdx = new Map<string, number>()
let planIdx = -1
events.forEach((e, i) => {
switch (e.type) {
case 'message': {
if (e.role !== 'agent' || !e.text) return
const last = rows[rows.length - 1]
if (last && last.kind === 'text') last.text += e.text
else rows.push({ kind: 'text', id: `t${i}`, text: e.text })
break
}
case 'tool_call': {
const id = e.id ?? `tc${i}`
const at = toolIdx.get(id)
if (at != null) {
const r = rows[at] as ToolRow
r.title = e.title ?? r.title
r.toolKind = e.kind ?? r.toolKind
r.status = e.status ?? r.status
} else {
toolIdx.set(id, rows.length)
rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] })
}
break
}
case 'tool_call_update': {
const id = e.id ?? `tu${i}`
let at = toolIdx.get(id)
if (at == null) {
at = rows.length
toolIdx.set(id, at)
rows.push({ kind: 'tool', id, diffs: [] })
}
const r = rows[at] as ToolRow
if (e.status) r.status = e.status
for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d)
break
}
case 'plan': {
if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries
else {
planIdx = rows.length
rows.push({ kind: 'plan', id: 'plan', entries: e.entries })
}
break
}
case 'permission':
rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision })
break
default:
break
}
})
return rows
}
function toolKindIcon(kind?: string) {
switch (kind) {
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" />
case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" />
case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" />
default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" />
}
}
function planMarker(status?: string) {
if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" />
return <Circle className="size-3.5 shrink-0 text-muted-foreground" />
}
const basename = (p: string) => p.split(/[\\/]/).pop() || p
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>
}
return (
<div className="flex flex-col gap-2 px-4 py-3">
{rows.map((row) => {
if (row.kind === 'text') {
return (
<p key={row.id} className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/90">
{row.text}
</p>
)
}
if (row.kind === 'tool') {
const running = row.status !== 'completed' && row.status !== 'failed'
return (
<div key={row.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
{running
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
{toolKindIcon(row.toolKind)}
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
</div>
{row.diffs.length > 0 && (
<div className="ml-7 flex flex-col gap-0.5">
{row.diffs.map((d) => (
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>
)}
</div>
)
}
if (row.kind === 'plan') {
return (
<div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2">
{row.entries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm text-foreground/90">
{planMarker(entry.status)}
<span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}>
{entry.content}
</span>
</div>
))}
</div>
)
}
// resolved permission
const denied = row.decision === 'reject' || row.decision === 'cancelled'
return (
<div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}>
{denied ? '✕' : '✓'}
<span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span>
</div>
)
})}
</div>
)
}
// ── In-run permission card ──────────────────────────────────────────
export function CodeRunPermissionRequest({
ask,
onDecide,
}: {
ask: PermissionAsk
onDecide: (decision: PermissionDecision) => void
}) {
const [busy, setBusy] = useState(false)
const decide = (d: PermissionDecision) => {
if (busy) return
setBusy(true)
onDecide(d)
}
const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50'
return (
<div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldQuestion className="size-4 shrink-0 text-amber-600" />
Permission needed
</div>
<p className="mt-1 text-sm text-muted-foreground">
The agent wants to: <span className="font-medium text-foreground">{ask.title}</span>
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" disabled={busy} onClick={() => decide('allow_once')}
className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}>
Allow
</button>
<button type="button" disabled={busy} onClick={() => decide('allow_always')}
className={cn(btn, 'border hover:bg-muted')}>
Always allow{ask.kind ? ` (${ask.kind})` : ''}
</button>
<button type="button" disabled={busy} onClick={() => decide('reject')}
className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}>
Deny
</button>
</div>
</div>
)
}
// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ──
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
export function CodingRunBlock({
item,
open,
onOpenChange,
onPermissionDecision,
}: {
item: ToolCall
open: boolean
onOpenChange: (open: boolean) => void
onPermissionDecision: (decision: PermissionDecision) => void
}) {
// Prefer the agent the backend actually ran (the chip) once the run returns; fall
// back to the requested input agent while it's still in flight. Never trust only the
// model's input — it can pass a stale agent the backend overrode with the chip.
const agent =
(item.result as { agent?: string } | undefined)?.agent ??
(item.input as { agent?: string } | undefined)?.agent
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
return (
<>
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} />
</ToolContent>
</Tool>
{item.pendingCodePermission && (
<CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} />
)}
</>
)
}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -258,6 +258,15 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#39;')
}
// Convert AI-generated plain text into the simple paragraph HTML the Tiptap
// editor expects (blank lines → paragraphs, single newlines → <br />).
function plainTextToHtml(text: string): string {
return text
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para.trim()).replace(/\n/g, '<br />')}</p>`)
.join('')
}
function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/
const match = re.exec(text)
@ -300,7 +309,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'
@ -511,7 +523,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
)
}
type ComposeMode = 'reply' | 'replyAll' | 'forward'
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new'
function ComposeToolbarButton({
editor,
@ -547,6 +559,29 @@ function ComposeToolbarButton({
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
return (
<div className="gmail-compose-toolbar">
<button
type="button"
className="gmail-compose-tool"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
aria-label="Undo"
title="Undo"
>
<Undo2 size={14} />
</button>
<button
type="button"
className="gmail-compose-tool"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
aria-label="Redo"
title="Redo"
>
<Redo2 size={14} />
</button>
<span className="gmail-compose-tool-sep" />
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleBold().run()}
@ -612,6 +647,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
)
}
type ContactSuggestion = {
name: string
email: string
}
function formatContactToken(c: ContactSuggestion): string {
return c.name ? `${c.name} <${c.email}>` : c.email
}
// Stable hue per email so the avatar circle keeps a consistent color.
function contactHue(email: string): number {
let h = 0
for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0
return h % 360
}
function contactInitial(c: ContactSuggestion): string {
const src = (c.name || c.email).trim()
return (src[0] || '?').toUpperCase()
}
// Renders a string with the matched substring wrapped in <mark>.
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>
const lower = text.toLowerCase()
const q = query.toLowerCase()
const idx = lower.indexOf(q)
if (idx < 0) return <>{text}</>
return (
<>
{text.slice(0, idx)}
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
{text.slice(idx + q.length)}
</>
)
}
function RecipientField({
label,
value,
@ -626,34 +698,123 @@ function RecipientField({
trailing?: React.ReactNode
}) {
const [draft, setDraft] = useState('')
const [suggestions, setSuggestions] = useState<ContactSuggestion[]>([])
const [activeIndex, setActiveIndex] = useState(0)
const [isFocused, setIsFocused] = useState(false)
const [queryShown, setQueryShown] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const fieldRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const queryTokenRef = useRef(0)
useEffect(() => {
if (autoFocus) inputRef.current?.focus()
}, [autoFocus])
const excludeEmails = useMemo(
() => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean),
[value],
)
// Debounced contact search — only runs when the user has actually typed
// something. An empty draft (including the post-pick reset) closes the menu.
useEffect(() => {
const trimmed = draft.trim()
if (!isFocused || !trimmed) {
queryTokenRef.current++
setSuggestions([])
return
}
const token = ++queryTokenRef.current
const timer = window.setTimeout(async () => {
try {
const result = (await window.ipc.invoke('gmail:searchContacts', {
query: draft,
limit: 8,
excludeEmails,
})) as { contacts?: ContactSuggestion[] } | undefined
if (token !== queryTokenRef.current) return
setSuggestions(result?.contacts ?? [])
setQueryShown(trimmed)
setActiveIndex(0)
} catch {
if (token !== queryTokenRef.current) return
setSuggestions([])
}
}, 60)
return () => window.clearTimeout(timer)
}, [draft, isFocused, excludeEmails])
// Keep the active row scrolled into view during keyboard navigation.
useEffect(() => {
const list = listRef.current
if (!list) return
const node = list.children[activeIndex] as HTMLElement | undefined
node?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, suggestions])
const commit = (raw: string) => {
const additions = splitAddresses(raw)
if (additions.length === 0) return
onChange(dedupeRecipients([...value, ...additions], new Set()))
setDraft('')
setSuggestions([])
}
const pickSuggestion = (c: ContactSuggestion) => {
commit(formatContactToken(c))
// Keep focus in the input so the user can keep typing more recipients.
inputRef.current?.focus()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
const hasSuggestions = suggestions.length > 0
if (event.key === 'ArrowDown' && hasSuggestions) {
event.preventDefault()
setActiveIndex((i) => (i + 1) % suggestions.length)
return
}
if (event.key === 'ArrowUp' && hasSuggestions) {
event.preventDefault()
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
return
}
if (event.key === 'Escape' && hasSuggestions) {
event.preventDefault()
setSuggestions([])
return
}
if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) {
// Prefer the highlighted suggestion when one is present.
if (hasSuggestions) {
event.preventDefault()
pickSuggestion(suggestions[activeIndex])
return
}
if (event.key === 'Enter' && draft.trim()) {
event.preventDefault()
commit(draft)
return
}
}
if (event.key === ',' || event.key === ';') {
if (draft.trim()) {
event.preventDefault()
commit(draft)
}
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
return
}
if (event.key === 'Backspace' && !draft && value.length > 0) {
onChange(value.slice(0, -1))
}
}
const showSuggestions = isFocused && suggestions.length > 0
return (
<div className="gmail-recipient-row">
<span className="gmail-recipient-label">{label}</span>
<div className="gmail-recipient-field">
<div className="gmail-recipient-field" ref={fieldRef}>
{value.map((token, index) => (
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
@ -674,7 +835,16 @@ function RecipientField({
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
onBlur={() => { if (draft.trim()) commit(draft) }}
onFocus={() => setIsFocused(true)}
onBlur={() => {
// Defer so a mousedown on a suggestion can pick it before the menu closes.
window.setTimeout(() => {
setIsFocused(false)
if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) {
commit(draft)
}
}, 80)
}}
onPaste={(event) => {
const text = event.clipboardData.getData('text')
if (text && /[,;\n]/.test(text)) {
@ -683,26 +853,126 @@ function RecipientField({
}
}}
/>
{showSuggestions && (
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
{suggestions.map((c, idx) => {
const hue = contactHue(c.email)
return (
<li
key={c.email}
role="option"
aria-selected={idx === activeIndex}
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
onMouseDown={(event) => {
// Prevent input blur before click fires.
event.preventDefault()
pickSuggestion(c)
}}
onMouseEnter={() => setActiveIndex(idx)}
>
<span
className="gmail-recipient-suggestion-avatar"
style={{ background: `hsl(${hue}, 60%, 42%)` }}
aria-hidden="true"
>
{contactInitial(c)}
</span>
<span className="gmail-recipient-suggestion-text">
<span className="gmail-recipient-suggestion-name">
<HighlightedText text={c.name || c.email} query={queryShown} />
</span>
{c.name && (
<span className="gmail-recipient-suggestion-email">
<HighlightedText text={c.email} query={queryShown} />
</span>
)}
</span>
</li>
)
})}
</ul>
)}
</div>
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
</div>
)
}
function ComposeBox({
const AI_GENERATE_SYSTEM =
'You write complete emails. Given an instruction, produce a subject line and a body. ' +
'Respond in EXACTLY this format and nothing else:\n' +
'Subject: <a concise, specific subject line>\n' +
'\n' +
'<the email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
'When the sender\'s first name is provided, sign off with that first name only; otherwise omit the sign-off name ' +
'(never write a placeholder like "[Your Name]").'
const AI_REWRITE_SYSTEM =
'You rewrite emails. Given the current subject and body plus an edit instruction, ' +
'produce the revised subject line and body. Keep the subject if it still fits, or ' +
'refine it so it matches the rewritten body. Respond in EXACTLY this format and nothing else:\n' +
'Subject: <the subject line>\n' +
'\n' +
'<the rewritten email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'Preserve the existing sign-off; do not invent placeholder names like "[Your Name]".'
// Split AI output of the form "Subject: …\n\n<body>" into its parts. If no
// subject line is present, the whole text is treated as the body.
function parseGeneratedEmail(text: string): { subject: string | null; body: string } {
const match = text.match(/^\s*Subject:\s*(.+?)(?:\r?\n|$)/i)
if (match) {
const subject = match[1].trim()
const body = text.slice(match.index! + match[0].length).replace(/^\s+/, '')
return { subject, body }
}
return { subject: null, body: text }
}
function firstNameFromDisplayName(name: string): string {
const trimmed = name.trim().replace(/^["']|["']$/g, '')
return trimmed.split(/\s+/)[0] || ''
}
// Guarantee the sender's first name signs off the email. If the model already
// ended with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
function ensureSignature(body: string, name: string): string {
const signer = name.trim()
if (!signer) return body
const trimmed = body.replace(/\s+$/, '')
// Check the last couple of lines so we don't double up an existing sign-off.
const tail = trimmed.split('\n').slice(-2).join('\n').toLowerCase()
if (tail.includes(signer.toLowerCase())) return trimmed
return `${trimmed}\n\n${signer}`
}
const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> = [
{ key: 'formal', label: 'Formal', instruction: 'Rewrite this email to be more formal and professional.' },
{ key: 'casual', label: 'Casual', instruction: 'Rewrite this email to be more casual and friendly.' },
{ key: 'shorter', label: 'Shorter', instruction: 'Rewrite this email to be more concise, keeping the key points.' },
{ key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' },
]
// Composer for replies, forwards, and (mode 'new') from-scratch emails. With a
// thread it renders as an inline card under the thread; in 'new' mode it has no
// thread and renders as a centered modal with the AI writing bar.
const ComposeBox = memo(function ComposeBox({
mode,
thread,
selfEmail,
selfEmail = '',
onClose,
}: {
mode: ComposeMode
thread: GmailThread
selfEmail: string
thread?: GmailThread
selfEmail?: string
onClose: () => void
}) {
const latest = latestMessage(thread)
const isNew = mode === 'new'
const latest = thread ? latestMessage(thread) : undefined
const initialRecipients = useMemo(
() => buildRecipients(mode, thread, selfEmail),
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
[mode, thread, selfEmail],
)
@ -711,10 +981,11 @@ function ComposeBox({
const [bccList, setBccList] = useState<string[]>([])
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
const [showBcc, setShowBcc] = useState<boolean>(false)
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply'
const initialContent = useMemo(() => {
if (!thread) return ''
if (mode === 'forward') return buildForwardedContent(thread)
// Gmail-side draft (user's own work) wins over the AI-generated draft.
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
@ -730,7 +1001,7 @@ function ComposeBox({
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
}),
],
editorProps: {
@ -782,13 +1053,177 @@ function ComposeBox({
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
}
// The signed-in account's display name, used to sign off AI-generated emails.
const [selfName, setSelfName] = useState<string>('')
const selfFirstName = useMemo(() => firstNameFromDisplayName(selfName), [selfName])
useEffect(() => {
if (!isNew) return
let cancelled = false
window.ipc.invoke('gmail:getAccountName', {})
.then((res) => { if (!cancelled && res?.name) setSelfName(res.name) })
.catch(() => {})
return () => { cancelled = true }
}, [isNew])
const [aiPrompt, setAiPrompt] = useState('')
const [generating, setGenerating] = useState(false)
// Once a draft has been generated, show a follow-up bar for iterative edits
// ("add a line about…", "remove the last paragraph", etc.). It hides again if
// the draft is emptied (e.g. undone), tracked via hasContent below.
const [hasGenerated, setHasGenerated] = useState(false)
const [hasContent, setHasContent] = useState(false)
// Keep hasContent in sync with the editor across typing, undo/redo, and clears.
useEffect(() => {
if (!editor) return
const sync = () => setHasContent(!editor.isEmpty)
sync()
editor.on('update', sync)
return () => { editor.off('update', sync) }
}, [editor])
// Clearing the body reverts the AI control to its "Write" state and drops the
// generated subject, so an emptied composer behaves like a fresh one. The
// hasGenerated guard avoids wiping a subject typed before any generation.
useEffect(() => {
if (hasGenerated && !hasContent) {
setHasGenerated(false)
setSubject('')
}
}, [hasGenerated, hasContent])
const runAi = async (instruction: string, aiMode: 'generate' | 'rewrite') => {
if (!editor || generating) return
const current = editor.getText().trim()
let prompt: string
let system: string
if (aiMode === 'generate') {
if (!instruction.trim()) { toast('Describe what to write.', 'error'); return }
system = AI_GENERATE_SYSTEM
const ctx: string[] = []
// Use the recipients' names (from the contacts picker) so the AI can
// address them naturally; fall back to the address when there's no name.
const recipientNames = toList
.map((token) => {
const name = extractName(token)
return name && name !== 'Unknown' ? name : extractAddress(token)
})
.filter(Boolean)
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
if (selfFirstName) ctx.push(`Sender's first name (sign off as this): ${selfFirstName}`)
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
} else {
if (!instruction.trim()) { toast('Describe the edit to make.', 'error'); return }
if (!current) { toast('Write something first.', 'error'); return }
system = AI_REWRITE_SYSTEM
const subjectLine = subject.trim() ? `Subject: ${subject.trim()}\n\n` : ''
prompt = `Instruction: ${instruction}\n\n---\n${subjectLine}${current}`
}
setGenerating(true)
try {
// Draft through Copilot: no model override, so the backend resolves the
// same default model/provider the Copilot chat uses (models.json).
const res = await window.ipc.invoke('llm:generate', { prompt, system })
if (res.error || !res.text) {
toast(res.error || 'No text was generated.', 'error')
return
}
// Replace via a tracked transaction (selectAll + insertContent) so the AI
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
if (aiMode === 'generate') {
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
if (generatedSubject) setSubject(generatedSubject)
// Always sign off with the account first name, even if the model omitted it.
const signed = ensureSignature(body, selfFirstName)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
setHasGenerated(true)
} else {
// Rewrites also regenerate the subject so it stays in sync with the body.
const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text)
if (rewrittenSubject) setSubject(rewrittenSubject)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run()
}
} catch (err) {
toast(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setGenerating(false)
}
}
// The single Write/Edit bar: generate a fresh draft until one exists, then
// switch to rewriting it. Clears the prompt after a run kicks off.
const runAiBar = async () => {
await runAi(aiPrompt, hasGenerated ? 'rewrite' : 'generate')
setAiPrompt('')
}
// Attachments staged for this message. contentBase64 is the raw file bytes,
// read in the renderer; the main process wraps them into the MIME on send.
const [attachments, setAttachments] = useState<
Array<{ id: string; filename: string; mimeType: string; size: number; contentBase64: string }>
>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Gmail rejects messages over ~25MB; base64 inflates bytes by ~33%.
const MAX_TOTAL_BYTES = 25 * 1024 * 1024
// Read a file's bytes as raw base64 (the part after the data: URL prefix).
const readAsBase64 = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
reader.onload = () => {
const result = String(reader.result)
const comma = result.indexOf(',')
resolve(comma >= 0 ? result.slice(comma + 1) : result)
}
reader.readAsDataURL(file)
})
const addFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return
const staged: typeof attachments = []
for (const file of Array.from(files)) {
try {
staged.push({
id: `${file.name}-${file.size}-${file.lastModified}`,
filename: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
contentBase64: await readAsBase64(file),
})
} catch {
toast(`Could not read ${file.name}.`, 'error')
}
}
setAttachments((prev) => {
const merged = [...prev]
for (const item of staged) {
if (!merged.some((a) => a.id === item.id)) merged.push(item)
}
const total = merged.reduce((sum, a) => sum + a.size, 0)
if (total > MAX_TOTAL_BYTES) {
toast('Attachments exceed the 25MB limit.', 'error')
return prev
}
return merged
})
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id))
}
const [sending, setSending] = useState(false)
const sendInGmail = async () => {
if (!editor || sending) return
const html = editor.getHTML()
const text = editor.getText().trim()
if (!text) {
toast('Draft is empty.', 'error')
toast(isNew ? 'Message is empty.' : 'Draft is empty.', 'error')
return
}
@ -798,25 +1233,29 @@ function ComposeBox({
}
// Build References chain from all known message ids (newest last).
const messageIds = thread.messages
const messageIds = (thread?.messages ?? [])
.map((m) => m.messageIdHeader)
.filter((v): v is string => Boolean(v))
const references = messageIds.join(' ')
const inReplyTo = latest?.messageIdHeader
const isForward = mode === 'forward'
// Only replies stay on the thread; forwards and new emails start fresh.
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
setSending(true)
try {
const result = await window.ipc.invoke('gmail:sendReply', {
threadId: isForward ? undefined : thread.threadId,
threadId: isThreaded ? thread?.threadId : undefined,
to: toList.join(', '),
cc: ccList.length ? ccList.join(', ') : undefined,
bcc: bccList.length ? bccList.join(', ') : undefined,
subject: subject.trim() || composeSubject(mode, thread.subject),
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
bodyHtml: html,
bodyText: text,
inReplyTo: isForward ? undefined : inReplyTo,
references: isForward ? undefined : references || undefined,
inReplyTo: isThreaded ? inReplyTo : undefined,
references: isThreaded ? references || undefined : undefined,
attachments: attachments.length
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
: undefined,
})
if (result.error) {
toast(`Send failed: ${result.error}`, 'error')
@ -832,7 +1271,7 @@ function ComposeBox({
}
const refineWithCopilot = () => {
if (!editor) return
if (!editor || !thread) return
const currentDraft = editor.getText().trim()
const threadSubject = thread.subject || '(No subject)'
@ -862,17 +1301,25 @@ function ComposeBox({
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}
return (
<div className="gmail-compose-card">
<div className="gmail-compose-header">
const card = (
<div
className={isNew ? 'gmail-compose-modal' : 'gmail-compose-card'}
onClick={isNew ? (event) => event.stopPropagation() : undefined}
>
<div className={isNew ? 'gmail-compose-modal-header' : 'gmail-compose-header'}>
<span>{modeLabel}</span>
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
<button
type="button"
className={isNew ? 'gmail-icon-button' : undefined}
onClick={onClose}
aria-label="Close compose"
>×</button>
</div>
<RecipientField
label="To"
value={toList}
onChange={setToList}
autoFocus={mode === 'forward'}
autoFocus={isNew || mode === 'forward'}
trailing={
<div className="gmail-recipient-toggles">
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
@ -882,18 +1329,83 @@ function ComposeBox({
/>
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
{mode === 'forward' && (
{isNew && (
<>
<div className="gmail-compose-ai-bar">
<input
className="gmail-compose-ai-input"
value={aiPrompt}
onChange={(event) => setAiPrompt(event.target.value)}
placeholder={hasGenerated
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
: 'Describe the email and let AI write it…'}
disabled={generating}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void runAiBar()
}
}}
/>
<button
type="button"
className="gmail-refine-button"
onClick={() => { void runAiBar() }}
disabled={generating}
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
>
{generating ? <LoaderIcon size={15} className="animate-spin" /> : <Sparkles size={15} />}
{generating
? (hasGenerated ? 'Editing…' : 'Writing…')
: (hasGenerated ? 'Edit' : 'Write')}
</button>
</div>
<div className="gmail-compose-ai-presets">
<button type="button" onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }} disabled={generating}>Improve</button>
{TONE_PRESETS.map((preset) => (
<button key={preset.key} type="button" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating}>{preset.label}</button>
))}
</div>
</>
)}
{(isNew || mode === 'forward') && (
<div className="gmail-compose-line">
<span className="gmail-compose-label">Subject</span>
<input
className="gmail-compose-subject-input"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Subject"
/>
</div>
)}
<EditorContent editor={editor} className="gmail-compose-editor" />
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
void addFiles(event.target.value ? event.currentTarget.files : null)
event.currentTarget.value = ''
}}
/>
{attachments.length > 0 && (
<div className="gmail-compose-attachments">
{attachments.map((att) => (
<div key={att.id} className="gmail-compose-attachment" title={att.filename}>
<Paperclip size={13} />
<span className="gmail-compose-attachment-name">{att.filename}</span>
<span className="gmail-compose-attachment-size">{formatAttachmentSize(att.size)}</span>
<button
type="button"
className="gmail-compose-attachment-remove"
onClick={() => removeAttachment(att.id)}
aria-label={`Remove ${att.filename}`}
>×</button>
</div>
))}
</div>
)}
{linkOpen && (
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
<input
@ -922,7 +1434,7 @@ function ComposeBox({
className="gmail-send-button"
onClick={() => { void sendInGmail() }}
disabled={sending}
title="Send this reply via Gmail"
title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'}
>
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
{sending ? 'Sending…' : 'Send'}
@ -930,19 +1442,40 @@ function ComposeBox({
<button
type="button"
className="gmail-refine-button"
onClick={refineWithCopilot}
title="Refine this draft with Copilot"
onClick={() => fileInputRef.current?.click()}
disabled={sending}
title="Attach files"
>
<Sparkles size={15} />
Refine
<Paperclip size={15} />
Attach
</button>
{thread && (
<button
type="button"
className="gmail-refine-button"
onClick={refineWithCopilot}
title="Refine this draft with Copilot"
>
<Sparkles size={15} />
Refine
</button>
)}
</div>
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
</div>
</div>
)
}
if (isNew) {
return (
<div className="gmail-compose-overlay" onClick={onClose}>
{card}
</div>
)
}
return card
})
function ThreadDetail({
thread,
@ -968,10 +1501,17 @@ function ThreadDetail({
return () => { cancelled = true }
}, [])
const canReplyAll = useMemo(() => {
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
return cc.length > 0 || to.length > 1
}, [thread, selfEmail])
const replyAllRecipients = useMemo(
() => buildRecipients('replyAll', thread, selfEmail),
[thread, selfEmail],
)
const canReplyAll = replyAllRecipients.cc.length > 0 || replyAllRecipients.to.length > 1
const replyAllButton = canReplyAll ? (
<button type="button" onClick={() => setComposeMode('replyAll')}>
<ReplyAll size={16} />
Reply All
</button>
) : null
const toggleExpand = useCallback((index: number) => {
setExpandedIndices((prev) => {
@ -1041,16 +1581,11 @@ function ThreadDetail({
</div>
<div className="gmail-thread-actions">
{replyAllButton}
<button type="button" onClick={() => setComposeMode('reply')}>
<Reply size={16} />
Reply
</button>
{canReplyAll && (
<button type="button" onClick={() => setComposeMode('replyAll')}>
<ReplyAll size={16} />
Reply all
</button>
)}
<button type="button" onClick={() => setComposeMode('forward')}>
<Forward size={16} />
Forward
@ -1124,6 +1659,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [composeOpen, setComposeOpen] = useState(false)
// Stable so the open composer isn't re-rendered on every inbox sync tick.
const closeCompose = useCallback(() => setComposeOpen(false), [])
// Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@ -1349,12 +1887,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
// when files change. Throttled to at most one reload per ~3s so a burst of
// backend writes (sync processing many threads sequentially) coalesces into
// a small number of in-place updates rather than a flicker storm.
// Suppressed while a thread is open (composing/reading); deferred until close.
// Suppressed while a thread is open (reading/replying) or the compose-new
// modal is open; deferred until whichever is open closes. A reload replaces
// the threads array and re-renders the whole inbox list (and any mounted
// ThreadDetail iframes) on the main thread — that re-render janks an open
// composer even though ComposeBox itself is memoized, so we pause it.
const pendingReloadRef = useRef(false)
const reloadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastReloadAtRef = useRef(0)
const isSelectedRef = useRef<string | null>(null)
isSelectedRef.current = selectedThreadId
const composeOpenRef = useRef(false)
composeOpenRef.current = composeOpen
const isRefreshingRef = useRef(false)
isRefreshingRef.current = refreshing
const otherHasThreadsRef = useRef(false)
@ -1364,7 +1908,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const doReload = useCallback(() => {
if (isRefreshingRef.current) return
if (isSelectedRef.current !== null) {
if (isSelectedRef.current !== null || composeOpenRef.current) {
pendingReloadRef.current = true
return
}
@ -1419,9 +1963,10 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
}
}, [triggerLiveReload])
// When user closes a thread, if updates arrived while they were reading, flush now.
// When the user closes the open thread or the compose-new modal, if updates
// arrived while it was open, flush them now.
useEffect(() => {
if (selectedThreadId !== null) return
if (selectedThreadId !== null || composeOpen) return
if (!pendingReloadRef.current) return
pendingReloadRef.current = false
lastReloadAtRef.current = Date.now()
@ -1429,7 +1974,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
if (otherHasThreadsRef.current) {
void reloadFirstPage('other', { silent: true })
}
}, [selectedThreadId, reloadFirstPage])
}, [selectedThreadId, composeOpen, reloadFirstPage])
// Manual refresh: wake the background sync loop. It updates inbox_lists/,
// the watcher fires, and triggerLiveReload picks up the changes. The
@ -1568,9 +2113,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
placeholder="Search loaded mail"
/>
</div>
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<div className="gmail-topbar-actions">
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<button type="button" className="gmail-icon-button" onClick={() => setComposeOpen(true)} aria-label="Compose new email">
<SquarePen size={18} />
</button>
</div>
</div>
{error && !hasAny ? (
@ -1637,6 +2187,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
</div>
)}
</div>
{composeOpen && <ComposeBox mode="new" onClose={closeCompose} />}
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</div>
)

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
import { ToolConnectionsCard } from '@/components/tool-connections-card'
interface TreeNode {
path: string
@ -54,7 +54,17 @@ type RawCalEvent = {
}
type EmailThread = { threadId: string; subject: string; from: string }
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
type SlackFeedMessage = {
id: string
workspaceName?: string
workspaceUrl?: string
channelId?: string
channelName?: string
author?: string
text: string
ts: string
url?: string
}
function greeting(): string {
const h = new Date().getHours()
@ -94,6 +104,28 @@ function relativeAgo(iso?: string): string {
return `${d}d ago`
}
function relativeSlackTs(ts: string): string {
const seconds = Number(ts.split('.')[0])
if (!Number.isFinite(seconds)) return ''
const iso = new Date(seconds * 1000).toISOString()
return relativeAgo(iso)
}
// Short, non-actionable copy for the home feed — the actionable fix lives in
// Settings, so every failure routes the user there.
function homeSlackErrorCopy(kind: string | null): string {
switch (kind) {
case 'not_authed':
return 'Slack needs reconnecting — open Settings → Connected accounts.'
case 'network':
return "Couldn't reach Slack. Check your connection."
case 'rate_limited':
return 'Slack is rate-limiting requests — will retry shortly.'
default:
return "Couldn't load Slack right now — see Settings."
}
}
function parseAllDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
@ -154,54 +186,6 @@ function triggerMeetingCapture(event: CalEvent, openConference: boolean) {
}
const CARD = 'rounded-xl border border-border bg-card p-4'
const TOOLKIT_PREVIEW_LIMIT = 8
let cachedToolkitPreviews: ToolkitPreview[] | null = null
let cachedToolkitLogosLoaded = false
function ToolkitPreviewIcon({
toolkit,
onInvalid,
}: {
toolkit: ToolkitPreview
onInvalid: (slug: string) => void
}) {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<img
src={toolkit.logo}
alt=""
className="hidden"
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
setLoaded(true)
} else {
onInvalid(toolkit.slug)
}
}}
onError={() => onInvalid(toolkit.slug)}
/>
)
}
return (
<div
title={`${toolkit.name}: ${toolkit.description}`}
aria-label={toolkit.name}
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
>
<img
src={toolkit.logo}
alt=""
className="size-5 shrink-0 object-contain"
onError={() => onInvalid(toolkit.slug)}
/>
</div>
)
}
export function HomeView({
tree,
@ -218,9 +202,10 @@ export function HomeView({
}: HomeViewProps) {
const [events, setEvents] = useState<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const [slackEnabled, setSlackEnabled] = useState(false)
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
const [slackError, setSlackError] = useState<string | null>(null)
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
const loadEvents = useCallback(async () => {
try {
@ -260,40 +245,23 @@ export function HomeView({
}
}, [])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
const loadSlackMessages = useCallback(async () => {
try {
const configured = await window.ipc.invoke('composio:is-configured', null)
if (!configured.configured) return
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
const previews = toolkits.items
.filter((toolkit) => Boolean(toolkit.meta.logo))
.slice(0, TOOLKIT_PREVIEW_LIMIT)
.map((toolkit) => ({
slug: toolkit.slug,
logo: toolkit.meta.logo,
name: toolkit.name,
description: toolkit.meta.description,
}))
cachedToolkitPreviews = previews
setToolkitPreviews(previews)
} catch {
cachedToolkitPreviews = []
} finally {
cachedToolkitLogosLoaded = true
setToolkitLogosLoaded(true)
const result = await window.ipc.invoke('slack:getRecentMessages', { limit: 5 })
setSlackEnabled(result.enabled)
setSlackMessages(result.messages)
setSlackError(result.error ?? null)
setSlackErrorKind(result.errorKind ?? null)
} catch (err) {
console.error('Home: failed to load Slack messages', err)
setSlackEnabled(false)
setSlackMessages([])
setSlackError(null)
setSlackErrorKind(null)
}
}, [])
const removeToolkitPreview = useCallback((slug: string) => {
setToolkitPreviews((prev) => {
const next = prev.filter((toolkit) => toolkit.slug !== slug)
cachedToolkitPreviews = next
return next
})
}, [])
useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos])
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages() }, [loadEvents, loadEmails, loadSlackMessages])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
@ -460,6 +428,53 @@ export function HomeView({
</div>
</div>
{/* Slack */}
{slackEnabled && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<MessageSquare className="size-[15px]" />
<span className="text-sm font-medium">Slack</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">Latest messages</span>
</div>
{slackError ? (
<div className="py-1 text-[12.5px] text-muted-foreground">{homeSlackErrorCopy(slackErrorKind)}</div>
) : slackMessages.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No messages worth surfacing right now.</div>
) : slackMessages.map((message, i) => (
<div
key={message.id}
className={`flex items-start gap-3 py-2 text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] text-muted-foreground">
<span className="truncate">{message.channelName ?? 'Slack'}</span>
{message.author && (
<>
<span className="shrink-0">·</span>
<span className="truncate">{message.author}</span>
</>
)}
<span className="shrink-0">·</span>
<span className="shrink-0">{relativeSlackTs(message.ts)}</span>
</div>
<div className="line-clamp-2 text-foreground">{message.text}</div>
</div>
{message.url && (
<button
type="button"
onClick={() => window.open(message.url, '_blank')}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-primary transition-colors hover:bg-accent"
>
Open
<ExternalLink className="size-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">
@ -524,41 +539,7 @@ export function HomeView({
)}
{/* Tool connections */}
<div className={CARD}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<span className="font-medium">Connect your tools.</span>
<span className="text-muted-foreground"> Bring context from the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
<ToolkitPreviewIcon
key={toolkit.slug}
toolkit={toolkit}
onInvalid={removeToolkitPreview}
/>
))}
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
>
Connections
<ArrowRight className="size-3" />
</button>
</div>
</div>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
<ToolConnectionsCard />
{/* Open chat CTA */}
{onOpenChat && (

View file

@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
<iframe
key={path}
src={iframeSrc}
sandbox="allow-scripts"
// `allow-popups` lets `target="_blank"` links reach the main process
// window-open handler, which routes them to the system browser. Plain
// links (same-frame navigations) are handled there via
// `will-frame-navigate`. No `allow-same-origin` — the doc stays
// origin-isolated.
sandbox="allow-scripts allow-popups"
className="h-full w-full border-0 bg-white"
title="HTML preview"
onLoad={() => setIframeLoaded(true)}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
ArrowLeft,
ChevronRight,
@ -47,17 +47,21 @@ export type KnowledgeViewActions = {
onOpenInNewTab?: (path: string) => void
}
export type KnowledgeViewMode = 'graph' | 'basis' | 'files'
type KnowledgeViewProps = {
tree: TreeNode[]
actions: KnowledgeViewActions
mode: KnowledgeViewMode
onModeChange: (mode: KnowledgeViewMode) => void
graphContent: ReactNode
basisContent: ReactNode
// Folder currently being browsed (null = root overview). Controlled by the
// app so drill-down participates in the global back/forward history.
folderPath: string | null
onNavigateFolder: (path: string | null) => void
onOpenNote: (path: string) => void
onOpenGraph: () => void
onOpenSearch: () => void
onOpenBases: () => void
onVoiceNoteCreated?: (path: string) => void
}
@ -161,12 +165,14 @@ function displayName(node: TreeNode): string {
export function KnowledgeView({
tree,
actions,
mode,
onModeChange,
graphContent,
basisContent,
folderPath,
onNavigateFolder,
onOpenNote,
onOpenGraph,
onOpenSearch,
onOpenBases,
onVoiceNoteCreated,
}: KnowledgeViewProps) {
const [renameTarget, setRenameTarget] = useState<string | null>(null)
@ -200,24 +206,34 @@ export function KnowledgeView({
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<h1 className="text-2xl font-bold tracking-tight">Brain</h1>
<p className="mt-1 text-sm text-muted-foreground">
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="inline-flex overflow-hidden rounded-lg border border-border bg-background">
<ViewModeButton
icon={Network}
label="Graph"
active={mode === 'graph'}
onClick={() => onModeChange('graph')}
/>
<ViewModeButton
icon={Table2}
label="Base"
active={mode === 'basis'}
onClick={() => onModeChange('basis')}
/>
<ViewModeButton
icon={FileText}
label="Files"
active={mode === 'files'}
onClick={() => onModeChange('files')}
/>
</div>
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
<button
type="button"
onClick={() => actions.createNote(currentFolder?.path)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<FilePlus className="size-4" />
<span>New note</span>
</button>
<button
type="button"
onClick={() => actions.addGoogleDoc(currentFolder?.path)}
@ -229,6 +245,15 @@ export function KnowledgeView({
</div>
</div>
{mode === 'graph' ? (
<div className="flex-1 min-h-0 overflow-hidden">
{graphContent}
</div>
) : mode === 'basis' ? (
<div className="flex-1 min-h-0 overflow-hidden">
{basisContent}
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-6">
{currentFolder ? (
@ -291,11 +316,12 @@ export function KnowledgeView({
<QuickActions
actions={actions}
currentFolder={currentFolder}
onOpenBases={onOpenBases}
onOpenSearch={onOpenSearch}
onFolderCreated={setRenameTarget}
/>
</div>
</div>
)}
</div>
)
}
@ -303,12 +329,12 @@ export function KnowledgeView({
function QuickActions({
actions,
currentFolder,
onOpenBases,
onOpenSearch,
onFolderCreated,
}: {
actions: KnowledgeViewActions
currentFolder: TreeNode | null
onOpenBases: () => void
onOpenSearch: () => void
onFolderCreated: (path: string) => void
}) {
// Inside a folder these target that folder; at the root they target knowledge/.
@ -318,6 +344,7 @@ function QuickActions({
<SectionHeader label="Quick actions" />
<div className="flex flex-wrap gap-2">
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
<QuickAction icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<QuickAction
icon={FolderPlus}
label="New folder"
@ -328,7 +355,6 @@ function QuickActions({
} catch { /* ignore */ }
}}
/>
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
<QuickAction
icon={FolderOpen}
label={`Reveal in ${getFileManagerName()}`}
@ -339,20 +365,26 @@ function QuickActions({
)
}
function SecondaryButton({
function ViewModeButton({
icon: Icon,
label,
active,
onClick,
}: {
icon: typeof SearchIcon
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
aria-pressed={active}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
)}
>
<Icon className="size-4" />
<span>{label}</span>
@ -556,7 +588,7 @@ function FolderDetail({
onClick={() => onNavigate(null)}
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Notes
Brain
</button>
{crumbs.map((c, i) => (
<span key={c.path} className="flex min-w-0 items-center gap-1.5">

View file

@ -1058,7 +1058,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
className="block w-full min-w-0 text-left text-sm font-medium text-foreground hover:underline"
>
<span className="block truncate">{note.name}</span>
</button>

View file

@ -2,13 +2,6 @@ import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
import { motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import {
OpenAIIcon,
@ -40,16 +33,23 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
export function LlmSetupStep({ state }: LlmSetupStepProps) {
const {
llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError,
llmProvider, setLlmProvider, modelsLoading, modelsError,
activeConfig, testState, setTestState, showApiKey,
showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders,
updateProviderConfig, handleTestAndSaveLlmConfig, handleBack,
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
connectedFlavors, handleNext, handleBack,
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
} = state
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
const modelsForProvider = modelsCatalog[llmProvider] || []
const showModelInput = isLocalProvider || modelsForProvider.length === 0
// Hosted providers (openai/anthropic/google) get a default model, so we only
// ask for a model on providers that truly need one (local/custom/gateway),
// or as a fallback if no model is set yet.
// Hosted providers (openai/anthropic/google) fetch their models from the API
// key on test, so they never need a manual model field. Only local/custom/
// gateway providers, where the user must specify a model, show the input.
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
const showModelInput = !hostedProviders.includes(llmProvider)
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
const isSelected = llmProvider === provider.id
@ -78,6 +78,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<div className="text-sm font-semibold">{provider.name}</div>
<div className="text-xs text-muted-foreground">{provider.description}</div>
</div>
{connectedFlavors.has(provider.id) && (
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
)}
</div>
</motion.button>
)
@ -87,7 +90,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Choose your model
Choose your provider
</h2>
<p className="text-base text-muted-foreground text-center mb-6">
Select a provider and configure your API key
@ -145,153 +148,33 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
{/* Separator */}
<div className="h-px bg-border my-4" />
{/* Model configuration */}
{/* Provider configuration */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Model Configuration</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2 min-w-0">
{/* Cloud providers get a default model auto-selected; only local/custom
providers (no catalog) need a model here. Users can pick any of the
provider's models later in the chat view. */}
{showModelInput && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Assistant Model
Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
) : (
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Enter model"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Knowledge Graph Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Meeting Notes Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.meetingNotesModel}
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.meetingNotesModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Track Block Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
{showApiKey && (
<div className="space-y-2">
@ -353,14 +236,23 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
</span>
)}
<Button
onClick={handleTestAndSaveLlmConfig}
variant="outline"
onClick={handleTestAndAddAnother}
disabled={!canTest || testState.status === "testing"}
>
Save & add another
</Button>
<Button
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
disabled={testState.status === "testing" || (!canTest && connectedFlavors.size === 0)}
className="min-w-[140px]"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
) : (
) : (canTest || connectedFlavors.size === 0) ? (
"Test & Continue"
) : (
"Continue"
)}
</Button>
</div>

View file

@ -41,6 +41,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
})
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
const [showMoreProviders, setShowMoreProviders] = useState(false)
// OAuth provider states
@ -98,7 +99,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
const canTest =
activeConfig.model.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
@ -410,43 +410,64 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
onComplete()
}, [onComplete])
const handleTestAndSaveLlmConfig = useCallback(async () => {
if (!canTest) return
// Test the active provider's credentials and persist its config. Returns
// whether it succeeded so callers can decide whether to advance or stay.
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
if (!canTest) return false
setTestState({ status: "testing" })
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = {
provider: {
flavor: llmProvider,
apiKey,
baseURL,
},
model,
knowledgeGraphModel,
meetingNotesModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
setTestState({ status: "success" })
await window.ipc.invoke("models:saveConfig", providerConfig)
window.dispatchEvent(new Event('models-config-changed'))
handleNext()
} else {
const provider = { flavor: llmProvider, apiKey, baseURL }
// Fetch the provider's models from the key — this both validates the
// credentials and gives us the list to populate the chat picker.
const result = await window.ipc.invoke("models:listForProvider", { provider })
if (!result.success) {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
return false
}
const models: string[] = result.models ?? []
const preferred = preferredDefaults[llmProvider]
const model =
(preferred && models.includes(preferred) && preferred) ||
models[0] || activeConfig.model.trim() || ""
await window.ipc.invoke("models:saveConfig", { provider, model, models })
window.dispatchEvent(new Event('models-config-changed'))
setTestState({ status: "success" })
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
return true
} catch (error) {
console.error("Connection test failed:", error)
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
return false
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
// Save the active provider and advance to the next step.
const handleTestAndSaveLlmConfig = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (ok) handleNext()
}, [testAndSaveActiveProvider, handleNext])
// Save the active provider but stay on the step. Switch to the next provider the
// user hasn't connected yet so the form is fresh and the buttons re-enable once
// they enter that key. (Clearing the current field instead left the buttons
// disabled on an empty form with no clear next step.)
const handleTestAndAddAnother = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (!ok) return
// setConnectedFlavors is async, so include the just-saved provider here.
const connectedNow = new Set(connectedFlavors).add(llmProvider)
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
const next = order.find(p => !connectedNow.has(p))
if (next) setLlmProvider(next)
setTestState({ status: "idle" })
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
@ -632,10 +653,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
showBaseURL,
isLocalProvider,
canTest,
connectedFlavors,
showMoreProviders,
setShowMoreProviders,
updateProviderConfig,
handleTestAndSaveLlmConfig,
handleTestAndAddAnother,
// OAuth state
providers,

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell } from "lucide-react"
import {
Dialog,
@ -25,8 +25,9 @@ import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -82,6 +83,12 @@ const tabs: TabConfig[] = [
icon: Palette,
description: "Customize the look and feel",
},
{
id: "notifications",
label: "Notifications",
icon: Bell,
description: "Choose which notifications you receive",
},
{
id: "note-tagging",
label: "Note Tagging",
@ -210,7 +217,7 @@ function ThemeOption({
}
function AppearanceSettings() {
const { theme, setTheme } = useTheme()
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
return (
<div className="space-y-6">
@ -240,6 +247,50 @@ function AppearanceSettings() {
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3">Chat</h4>
<p className="text-xs text-muted-foreground mb-4">
Choose where chat sits when another pane is open
</p>
<div className="grid grid-cols-2 gap-3">
<ThemeOption
label="Chat right"
icon={PanelRight}
isSelected={chatPanePlacement === "right"}
onClick={() => setChatPanePlacement("right")}
/>
<ThemeOption
label="Chat middle"
icon={MessageCircle}
isSelected={chatPanePlacement === "middle"}
onClick={() => setChatPanePlacement("middle")}
/>
</div>
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
<p className="text-xs text-muted-foreground mb-4">
Choose how much width chat gets when another pane is open
</p>
<div className="grid grid-cols-3 gap-3">
<ThemeOption
label="Chat smaller"
icon={MessageCircle}
isSelected={chatPaneSize === "chat-smaller"}
onClick={() => setChatPaneSize("chat-smaller")}
/>
<ThemeOption
label="Chat equal"
icon={Monitor}
isSelected={chatPaneSize === "chat-equal"}
onClick={() => setChatPaneSize("chat-equal")}
/>
<ThemeOption
label="Chat bigger"
icon={PanelRight}
isSelected={chatPaneSize === "chat-bigger"}
onClick={() => setChatPaneSize("chat-bigger")}
/>
</div>
</div>
</div>
)
}
@ -277,17 +328,27 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
"openai-compatible": "http://localhost:1234/v1",
}
type ProviderModelConfig = {
apiKey: string
baseURL: string
models: string[]
knowledgeGraphModel: string
meetingNotesModel: string
liveNoteAgentModel: string
autoPermissionDecisionModel: string
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
@ -313,7 +374,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
@ -388,6 +449,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "",
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
};
}
}
@ -406,6 +468,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
};
}
return next;
@ -481,6 +544,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -515,6 +579,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
@ -546,6 +611,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -553,7 +619,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
}))
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
@ -811,6 +877,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</Select>
)}
</div>
{/* Auto-permission model */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.autoPermissionDecisionModel}
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.autoPermissionDecisionModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* API Key */}
@ -1659,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>
)
@ -1712,6 +1882,7 @@ function AgentStatusRow({
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [enabled, setEnabled] = useState(false)
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
@ -1736,7 +1907,10 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
setLoading(true)
try {
const result = await window.ipc.invoke("codeMode:getConfig", null)
if (!cancelled) setEnabled(result.enabled)
if (!cancelled) {
setEnabled(result.enabled)
setApprovalPolicy(result.approvalPolicy ?? 'ask')
}
} catch {
if (!cancelled) setEnabled(false)
} finally {
@ -1752,7 +1926,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
setSaving(true)
setEnabled(next)
try {
await window.ipc.invoke("codeMode:setConfig", { enabled: next })
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
window.dispatchEvent(new Event("code-mode-config-changed"))
toast.success(next ? "Code mode enabled" : "Code mode disabled")
} catch {
@ -1761,7 +1935,22 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
} finally {
setSaving(false)
}
}, [])
}, [approvalPolicy])
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
const prev = approvalPolicy
setSaving(true)
setApprovalPolicy(next)
try {
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
window.dispatchEvent(new Event("code-mode-config-changed"))
} catch {
setApprovalPolicy(prev)
toast.error("Failed to update approval policy")
} finally {
setSaving(false)
}
}, [enabled, approvalPolicy])
const anyReady = status?.claude.installed && status?.claude.signedIn
|| status?.codex.installed && status?.codex.signedIn
@ -1781,14 +1970,21 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
<p>
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
on your machine. Pick the agent inline from the composer; the assistant calls it via
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code>
and streams results back into chat.
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
and streams its work tool calls, file diffs, and approvals back into chat.
</p>
<p>
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">
@ -1806,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>
@ -1833,12 +2031,41 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
/>
</div>
{enabled && (
<div className="rounded-md border px-3 py-3 space-y-2">
<div className="text-sm font-medium">Approvals</div>
<div className="text-xs text-muted-foreground">
How the coding agent checks in before changing files or running commands. You always see
everything it does in the timeline this only controls the prompts.
</div>
<Select
value={approvalPolicy}
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask every time</SelectItem>
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
</div>
</div>
)}
{enabled && status && !anyReady && (
<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>
)}
@ -1846,6 +2073,99 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Notification Settings ---
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission"
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
{
key: "chat_completion",
label: "Chat responses",
description: "When an agent finishes responding while the app is in the background.",
},
{
key: "new_email",
label: "New email",
description: "When a new email arrives during sync while the app is in the background.",
},
{
key: "agent_permission",
label: "Permission requests",
description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.",
},
]
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [categories, setCategories] = useState<Record<NotificationCategoryKey, boolean> | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
async function load() {
try {
const result = await window.ipc.invoke("notifications:getSettings", null)
if (!cancelled) setCategories(result.categories)
} catch {
if (!cancelled) toast.error("Failed to load notification settings")
}
}
load()
return () => { cancelled = true }
}, [dialogOpen])
const handleToggle = useCallback(async (key: NotificationCategoryKey, next: boolean) => {
// Optimistic update with rollback on failure.
const previous = categories
if (!previous) return
const updated = { ...previous, [key]: next }
setCategories(updated)
setSaving(true)
try {
await window.ipc.invoke("notifications:setSettings", { categories: updated })
} catch {
setCategories(previous)
toast.error("Failed to update notification settings")
} finally {
setSaving(false)
}
}, [categories])
if (!categories) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-5">
<div className="text-sm text-muted-foreground leading-relaxed">
Choose which desktop notifications Rowboat sends you. Ambient notifications are only shown
when the app is in the background.
</div>
<div className="space-y-2">
{NOTIFICATION_CATEGORIES.map((cat) => (
<div key={cat.key} className="rounded-md border px-3 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{cat.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">{cat.description}</div>
</div>
<Switch
checked={categories[cat.key]}
onCheckedChange={(next) => handleToggle(cat.key, next)}
disabled={saving}
/>
</div>
))}
</div>
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
@ -1893,7 +2213,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -2001,7 +2321,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (
@ -2024,6 +2344,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "notifications" ? (
<NotificationSettings dialogOpen={open} />
) : activeTab === "help" ? (
<HelpSettings />
) : activeTab === "code-mode" ? (

View file

@ -17,17 +17,12 @@ import {
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner"
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
interface AccountSettingsProps {
dialogOpen: boolean
}
function formatPlanName(plan: string | null | undefined) {
if (!plan) return 'No Plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
}
function CreditUsageBar({ label, bucket, helper }: {
label: string
bucket: BillingUsageBucket
@ -62,7 +57,8 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [connecting, setConnecting] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
const checkConnection = useCallback(async () => {
try {
@ -197,7 +193,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{formatPlanName(billing.subscriptionPlan)}
{currentPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -209,12 +205,12 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
})() : billing.subscriptionStatus ? (
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
) : null}
{!billing.subscriptionPlan && (
{!billing.subscriptionPlanId && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
{!billing.subscriptionPlanId ? 'Subscribe' : currentPlan?.category === 'free' ? 'Upgrade' : 'Change plan'}
</Button>
</div>
<div className="space-y-3 border-t pt-3">

View file

@ -1,19 +1,37 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { useConnectors } from "@/hooks/useConnectors"
import { useConnectors, actionableSlackError } from "@/hooks/useConnectors"
interface ConnectedAccountsSettingsProps {
dialogOpen: boolean
}
function relativeTime(iso?: string): string {
if (!iso) return "never"
const then = Date.parse(iso)
if (!Number.isFinite(then)) return "never"
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return "just now"
const diffMin = Math.round(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return `${Math.round(diffHr / 24)}d ago`
}
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
const c = useConnectors(dialogOpen)
// Windows exclusively locks Slack's Cookies DB while it runs, so we offer a
// "quit Slack first" one-click import there. mac/Linux import with Slack open.
const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = c.providerStates[provider] || {
@ -237,6 +255,224 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
{/* Team Communication Section */}
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-emerald-600 truncate">
{c.slackWorkspaces.map(workspace => workspace.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">Send messages and view channels</span>
)}
</div>
</div>
<div className="shrink-0">
{c.slackLoading || c.slackDiscovering ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.slackEnabled ? (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackDisable}
className="h-7 px-3 text-xs"
>
Disable
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
className="h-7 px-3 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-10 space-y-2">
{c.slackNeedsAuth ? (
<>
<p className="text-xs text-muted-foreground">
{c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'}
</p>
<div className="flex flex-wrap items-center gap-2.5">
<Button
size="sm"
onClick={c.handleSlackImportDesktop}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
>
{c.slackAuthImporting ? <Loader2 className="size-3 animate-spin" /> : "Connect Slack"}
</Button>
{isWindows && (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackQuitAndImport}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
title="Closes Slack so its data unlocks, then connects"
>
Quit Slack &amp; connect
</Button>
)}
<button
type="button"
onClick={() => c.setSlackCurlOpen(!c.slackCurlOpen)}
className="text-xs text-primary underline-offset-2 hover:underline"
>
Paste from browser instead
</button>
</div>
{c.slackCurlOpen && (
<div className="space-y-1.5">
<p className="text-[11px] leading-relaxed text-muted-foreground">
In a browser signed in to Slack, open DevTools Network, click any
request to <code>app.slack.com</code>, right-click Copy Copy as cURL,
then paste it below.
</p>
<Textarea
value={c.slackCurlValue}
onChange={(event) => c.setSlackCurlValue(event.target.value)}
placeholder="curl 'https://your-team.slack.com/api/...' -H 'Cookie: d=xoxd-...' ..."
className="min-h-20 text-[11px] font-mono"
disabled={c.slackCurlSubmitting}
/>
<Button
size="sm"
onClick={c.handleSlackParseCurl}
disabled={c.slackCurlSubmitting || c.slackCurlValue.trim().length === 0}
className="h-7 px-3 text-xs"
>
{c.slackCurlSubmitting ? <Loader2 className="size-3 animate-spin" /> : "Connect with cURL"}
</Button>
</div>
)}
</>
) : c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(workspace => (
<label key={workspace.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(workspace.url)}
onChange={(event) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (event.target.checked) next.add(workspace.url)
else next.delete(workspace.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{workspace.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
{/* Knowledge Sources Section */}
{c.slackEnabled && (
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Knowledge Sources
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack to knowledge</span>
<span className="text-xs text-muted-foreground truncate">
Sync selected channels into the knowledge graph
</span>
</div>
</div>
<Switch
checked={c.slackKnowledgeEnabled}
onCheckedChange={c.setSlackKnowledgeEnabled}
disabled={c.slackKnowledgeSaving}
/>
</div>
<div className="mt-2 space-y-2">
<Textarea
value={c.slackKnowledgeChannels}
onChange={(event) => c.setSlackKnowledgeChannels(event.target.value)}
placeholder={c.slackWorkspaces.length > 1 ? "https://team.slack.com #engineering" : "#engineering"}
className="min-h-20 text-xs"
disabled={!c.slackKnowledgeEnabled || c.slackKnowledgeSaving}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
One channel per line. Use channel names or IDs.
</span>
{(c.slackKnowledgeDirty || c.slackKnowledgeSaving) && (
<Button
size="sm"
onClick={c.handleSlackKnowledgeSave}
disabled={c.slackKnowledgeSaving || (c.slackKnowledgeEnabled && c.slackKnowledgeChannels.trim().length === 0)}
className="h-7 px-3 text-xs"
>
{c.slackKnowledgeSaving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
</Button>
)}
</div>
{c.slackKnowledgeEnabled && c.slackSyncStatuses.filter(s => s.enabled).map(status => (
<div key={status.id} className="flex items-center gap-1.5 text-xs">
{status.lastStatus === 'error' ? (
<span className="text-amber-600 truncate">
Sync failing {actionableSlackError(status.lastError?.kind, status.lastError?.message)}
</span>
) : status.lastSyncAt ? (
<span className="text-muted-foreground">Last synced {relativeTime(status.lastSyncAt)}</span>
) : (
<span className="text-muted-foreground">Not synced yet first sync runs shortly</span>
)}
</div>
))}
</div>
</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,
@ -60,6 +62,7 @@ import { SettingsDialog } from "@/components/settings-dialog"
import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "@/lib/toast"
import { getBillingPlanData } from "@x/shared/dist/billing.js"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod"
@ -89,18 +92,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`
}
function formatAgo(ms: number): string {
const diffMs = Math.max(0, Date.now() - ms)
const min = Math.floor(diffMs / 60000)
@ -168,17 +159,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 +405,14 @@ function SyncStatusBar() {
export function SidebarContentPanel({
tree,
onSelectFile,
knowledgeActions,
bgTaskSummaries = [],
onOpenMeetings,
onOpenCode,
onOpenBgTasks,
onOpenAgent,
recentRuns = [],
onOpenRun,
onOpenChatHistory,
onOpenEmail,
onOpenHome,
onNewChat,
@ -440,12 +433,28 @@ export function SidebarContentPanel({
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected)
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
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
@ -512,7 +521,7 @@ export function SidebarContentPanel({
const out: TreeNode[] = []
const walk = (nodes: TreeNode[]) => {
for (const n of nodes) {
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace' || n.path === 'knowledge/Agent Notes') continue
if (n.kind === 'file') out.push(n)
else if (n.children?.length) walk(n.children)
}
@ -521,62 +530,19 @@ export function SidebarContentPanel({
return out
.filter((n) => n.stat?.mtimeMs)
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
.slice(0, 5)
.slice(0, 10)
}, [tree])
// Recents: most recently touched notes / agents / chats, interleaved by
// recency. Capped per type (3 notes, 2 agents, 1 chat) and 5 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, 3)) {
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, 2)) {
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, 1)) {
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, 5)
}, [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 +800,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'}
@ -842,7 +816,7 @@ export function SidebarContentPanel({
>
<FileText className="size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Knowledge</span>
<span className="truncate">Brain</span>
{knowledgeUpdatedLabel && (
<span className="truncate text-[11px] text-muted-foreground">{knowledgeUpdatedLabel}</span>
)}
@ -895,38 +869,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>
)
)}
@ -939,7 +918,7 @@ export function SidebarContentPanel({
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{formatBillingPlanName(billing.subscriptionPlan)}
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -954,7 +933,7 @@ export function SidebarContentPanel({
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
{!billing.subscriptionPlan || billing.subscriptionPlan === 'free' || billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
{!billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'}
</button>
</div>
</div>

View file

@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Plug } from 'lucide-react'
import { SettingsDialog } from '@/components/settings-dialog'
import { cn } from '@/lib/utils'
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
const TOOLKIT_PREVIEW_LIMIT = 8
let cachedToolkitPreviews: ToolkitPreview[] | null = null
let cachedToolkitLogosLoaded = false
function ToolkitPreviewIcon({
toolkit,
onInvalid,
}: {
toolkit: ToolkitPreview
onInvalid: (slug: string) => void
}) {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<img
src={toolkit.logo}
alt=""
className="hidden"
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
setLoaded(true)
} else {
onInvalid(toolkit.slug)
}
}}
onError={() => onInvalid(toolkit.slug)}
/>
)
}
return (
<div
title={`${toolkit.name}: ${toolkit.description}`}
aria-label={toolkit.name}
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
>
<img
src={toolkit.logo}
alt=""
className="size-5 shrink-0 object-contain"
onError={() => onInvalid(toolkit.slug)}
/>
</div>
)
}
export function ToolConnectionsCard({ className }: { className?: string }) {
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
const configured = await window.ipc.invoke('composio:is-configured', null)
if (!configured.configured) return
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
const previews = toolkits.items
.filter((toolkit) => Boolean(toolkit.meta.logo))
.slice(0, TOOLKIT_PREVIEW_LIMIT)
.map((toolkit) => ({
slug: toolkit.slug,
logo: toolkit.meta.logo,
name: toolkit.name,
description: toolkit.meta.description,
}))
cachedToolkitPreviews = previews
setToolkitPreviews(previews)
} catch {
cachedToolkitPreviews = []
} finally {
cachedToolkitLogosLoaded = true
setToolkitLogosLoaded(true)
}
}, [])
const removeToolkitPreview = useCallback((slug: string) => {
setToolkitPreviews((prev) => {
const next = prev.filter((toolkit) => toolkit.slug !== slug)
cachedToolkitPreviews = next
return next
})
}, [])
useEffect(() => {
void loadConnectorLogos()
}, [loadConnectorLogos])
return (
<>
<div className={cn('rounded-xl border border-border bg-card p-4', className)}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<span className="text-muted-foreground">Bring context from and take action in the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
<ToolkitPreviewIcon
key={toolkit.slug}
toolkit={toolkit}
onInvalid={removeToolkitPreview}
/>
))}
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
>
Connections
<ArrowRight className="size-3" />
</button>
</div>
</div>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
</>
)
}

View file

@ -3,16 +3,32 @@
import * as React from "react"
export type Theme = "light" | "dark" | "system"
export type ChatPanePlacement = "right" | "middle"
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
type ThemeContextProps = {
theme: Theme
resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void
chatPanePlacement: ChatPanePlacement
setChatPanePlacement: (placement: ChatPanePlacement) => void
chatPaneSize: ChatPaneSize
setChatPaneSize: (size: ChatPaneSize) => void
}
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
const STORAGE_KEY = "rowboat-theme"
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
return value === "right" || value === "middle"
}
function isChatPaneSize(value: string | null): value is ChatPaneSize {
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
}
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light"
@ -39,6 +55,16 @@ export function ThemeProvider({
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
return stored || defaultTheme
})
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
if (typeof window === "undefined") return "right"
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
return isChatPanePlacement(stored) ? stored : "right"
})
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
if (typeof window === "undefined") return "chat-smaller"
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
return isChatPaneSize(stored) ? stored : "chat-smaller"
})
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
if (theme === "system") return getSystemTheme()
@ -76,13 +102,27 @@ export function ThemeProvider({
setThemeState(newTheme)
}, [])
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
setChatPanePlacementState(placement)
}, [])
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
setChatPaneSizeState(size)
}, [])
const contextValue = React.useMemo<ThemeContextProps>(
() => ({
theme,
resolvedTheme,
setTheme,
chatPanePlacement,
setChatPanePlacement,
chatPaneSize,
setChatPaneSize,
}),
[theme, resolvedTheme, setTheme]
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
)
return (

View file

@ -12,6 +12,58 @@ export interface ProviderStatus {
error?: string
}
type KnowledgeSourceConfig = {
id: string
provider: 'gmail' | 'meeting' | 'voice_memo' | 'slack' | 'github' | 'linear'
enabled: boolean
artifactDir: string
syncMode: 'file' | 'poll' | 'event' | 'manual'
intervalMs?: number
scopes: Array<{ type: string; id: string; name?: string; workspaceUrl?: string }>
instructions?: string
filters?: Record<string, unknown>
}
export type SlackSyncStatus = {
id: string
enabled: boolean
lastSyncAt?: string
lastStatus?: 'ok' | 'error'
lastError?: { kind: string; message: string }
nextDueAt?: string
}
/**
* Map a structured agent-slack failure to actionable user copy. The key
* distinction (raised by real usage): a missing Slack desktop app needs a
* different instruction than a signed-out one.
*/
export function actionableSlackError(kind?: string, message?: string): string {
// Windows locks Slack's Cookies/LevelDB files while it's running, so the
// desktop import copy fails with EBUSY. This can surface under any kind, so
// check the message first.
if (message && /EBUSY|resource busy|locked|copyfile/i.test(message)) {
return 'Slack is open and locking its data. Click "Quit Slack & connect" to close it automatically, or use "Paste from browser instead".'
}
switch (kind) {
case 'not_installed':
return 'The Slack helper is unavailable in this build. Please update or reinstall Rowboat.'
case 'network':
return "Couldn't reach Slack. Check your internet connection and try again."
case 'rate_limited':
return 'Slack is rate-limiting requests right now. Wait a minute and try again.'
case 'bad_channel':
return message || "A configured channel couldn't be found. Check the channel names in Settings."
case 'not_authed':
if (message && /Desktop data not found|not supported/i.test(message)) {
return 'No Slack desktop app was found. Install Slack, sign in to your workspace, then click Connect.'
}
return 'No signed-in Slack account found. Open the Slack desktop app, sign in, then click Connect.'
default:
return message || "Couldn't connect to Slack. Please try again."
}
}
export function useConnectors(active: boolean) {
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -37,6 +89,23 @@ export function useConnectors(active: boolean) {
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// True when discovery succeeded but no workspaces are connected yet, so the
// user needs to import auth from the Slack desktop app (fixes the silent
// "Enable" bounce-back where the button never progressed).
const [slackNeedsAuth, setSlackNeedsAuth] = useState(false)
const [slackAuthImporting, setSlackAuthImporting] = useState(false)
// Cross-OS "paste cURL from a browser tab" fallback when desktop import fails.
const [slackCurlOpen, setSlackCurlOpen] = useState(false)
const [slackCurlValue, setSlackCurlValue] = useState("")
const [slackCurlSubmitting, setSlackCurlSubmitting] = useState(false)
const [slackKnowledgeEnabled, setSlackKnowledgeEnabled] = useState(false)
const [slackKnowledgeChannels, setSlackKnowledgeChannels] = useState("")
const [slackKnowledgeSaving, setSlackKnowledgeSaving] = useState(false)
// Snapshot of the last-persisted knowledge config, used to detect unsaved
// edits so the Save button only appears when there's something to save.
const [slackKnowledgeSavedEnabled, setSlackKnowledgeSavedEnabled] = useState(false)
const [slackKnowledgeSavedChannels, setSlackKnowledgeSavedChannels] = useState("")
const [slackSyncStatuses, setSlackSyncStatuses] = useState<SlackSyncStatus[]>([])
// Composio Gmail/Calendar sync was removed. These flags are seeded false
// and never flipped — the IPC that used to set them is gone. The setters
@ -121,26 +190,105 @@ export function useConnectors(active: boolean) {
const handleSlackEnable = useCallback(async () => {
setSlackDiscovering(true)
setSlackDiscoverError(null)
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
setSlackPickerOpen(true)
try {
const result = await window.ipc.invoke('slack:listWorkspaces', null)
if (result.error || result.workspaces.length === 0) {
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
setSlackAvailableWorkspaces([])
setSlackPickerOpen(true)
} else {
if (result.workspaces.length > 0) {
// Already-connected workspaces → straight to the picker.
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
setSlackPickerOpen(true)
} else {
// CLI ran but nothing is connected yet (or it errored): offer a
// concrete next step instead of a dead-end message.
setSlackAvailableWorkspaces([])
setSlackNeedsAuth(true)
setSlackDiscoverError(result.error ? actionableSlackError(result.errorKind, result.error) : null)
}
} catch (error) {
console.error('Failed to discover Slack workspaces:', error)
setSlackDiscoverError('Failed to discover Slack workspaces')
setSlackPickerOpen(true)
setSlackNeedsAuth(true)
setSlackDiscoverError("Couldn't start Slack discovery. Please try again.")
} finally {
setSlackDiscovering(false)
}
}, [])
// Shared success path for both auth methods: show the discovered workspaces
// in the picker, preselected. Returns true when workspaces were found.
const applyDiscoveredWorkspaces = useCallback((result: { ok: boolean; workspaces: Array<{ url: string; name: string }>; error?: string; errorKind?: string }) => {
if (result.ok && result.workspaces.length > 0) {
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w) => w.url)))
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
return true
}
setSlackDiscoverError(actionableSlackError(result.errorKind, result.error))
return false
}, [])
// Import xoxc token + cookie from the signed-in Slack desktop app, then show
// the discovered workspaces in the picker.
const handleSlackImportDesktop = useCallback(async () => {
setSlackAuthImporting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:importDesktopAuth', null)
// Desktop import is best-effort: it fails when Slack is running and locks
// its Cookies DB (EBUSY on Windows), or on unsupported Slack builds. On
// any failure, reveal the browser-paste fallback so the user is never
// stuck — it has no file-lock dependency and works cross-OS.
if (!applyDiscoveredWorkspaces(result)) {
setSlackCurlOpen(true)
}
} catch (error) {
console.error('Failed to import Slack desktop auth:', error)
setSlackDiscoverError("Couldn't import from the Slack desktop app. Please try again, or paste from your browser below.")
setSlackCurlOpen(true)
} finally {
setSlackAuthImporting(false)
}
}, [applyDiscoveredWorkspaces])
// Windows-only: force-quit Slack (releases its Cookies-DB lock) then import.
// One click instead of the manual taskkill dance.
const handleSlackQuitAndImport = useCallback(async () => {
setSlackAuthImporting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:quitAndImportDesktop', null)
if (!applyDiscoveredWorkspaces(result)) {
setSlackCurlOpen(true)
}
} catch (error) {
console.error('Failed to quit Slack and import:', error)
setSlackDiscoverError("Couldn't import after closing Slack. Please try again, or paste from your browser below.")
setSlackCurlOpen(true)
} finally {
setSlackAuthImporting(false)
}
}, [applyDiscoveredWorkspaces])
// Fallback: parse a "Copy as cURL" request pasted from a signed-in Slack web
// tab. Works on every OS — no desktop app, leveldb, or keychain needed.
const handleSlackParseCurl = useCallback(async () => {
setSlackCurlSubmitting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:parseCurlAuth', { curl: slackCurlValue })
applyDiscoveredWorkspaces(result)
} catch (error) {
console.error('Failed to parse Slack cURL:', error)
setSlackDiscoverError("Couldn't read that cURL command. Please try again.")
} finally {
setSlackCurlSubmitting(false)
}
}, [applyDiscoveredWorkspaces, slackCurlValue])
const handleSlackSaveWorkspaces = useCallback(async () => {
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
try {
@ -149,6 +297,7 @@ export function useConnectors(active: boolean) {
setSlackEnabled(true)
setSlackWorkspaces(selected)
setSlackPickerOpen(false)
setSlackNeedsAuth(false)
toast.success('Slack enabled')
} catch (error) {
console.error('Failed to save Slack config:', error)
@ -165,6 +314,22 @@ export function useConnectors(active: boolean) {
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
await window.ipc.invoke('knowledgeSources:upsert', {
id: 'slack',
provider: 'slack',
enabled: false,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes: [],
})
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
setSlackKnowledgeSavedEnabled(false)
setSlackKnowledgeSavedChannels("")
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
@ -174,6 +339,93 @@ export function useConnectors(active: boolean) {
}
}, [])
const refreshSlackKnowledgeStatus = useCallback(async () => {
try {
const result = await window.ipc.invoke('slack:knowledgeStatus', null)
setSlackSyncStatuses(result.sources)
} catch (error) {
console.error('Failed to load Slack knowledge status:', error)
setSlackSyncStatuses([])
}
}, [])
const refreshKnowledgeSources = useCallback(async () => {
try {
const result = await window.ipc.invoke('knowledgeSources:getConfig', null)
const slackSource = (result.sources as KnowledgeSourceConfig[]).find(source => source.id === 'slack')
const enabled = Boolean(slackSource?.enabled)
const channels = (slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => {
const channel = scope.name || scope.id
return scope.workspaceUrl ? `${scope.workspaceUrl} ${channel}` : channel
})
.join('\n')
setSlackKnowledgeEnabled(enabled)
setSlackKnowledgeChannels(channels)
setSlackKnowledgeSavedEnabled(enabled)
setSlackKnowledgeSavedChannels(channels)
} catch (error) {
console.error('Failed to load knowledge sources:', error)
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
setSlackKnowledgeSavedEnabled(false)
setSlackKnowledgeSavedChannels("")
}
}, [])
const parseSlackKnowledgeScopes = useCallback(() => {
const defaultWorkspaceUrl = slackWorkspaces.length === 1 ? slackWorkspaces[0]?.url : undefined
return slackKnowledgeChannels
.split(/\n+/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const parts = line.split(/\s+/)
const first = parts[0] ?? ''
const hasWorkspace = /^https?:\/\//.test(first)
const workspaceUrl = hasWorkspace ? first : defaultWorkspaceUrl
const channelRaw = hasWorkspace ? parts.slice(1).join(' ') : line
const channel = channelRaw.trim()
return {
type: 'channel',
id: channel.replace(/^#/, ''),
name: channel.startsWith('#') ? channel : `#${channel}`,
workspaceUrl,
}
})
.filter(scope => scope.id.length > 0)
}, [slackKnowledgeChannels, slackWorkspaces])
const handleSlackKnowledgeSave = useCallback(async () => {
try {
setSlackKnowledgeSaving(true)
const scopes = parseSlackKnowledgeScopes()
await window.ipc.invoke('knowledgeSources:upsert', {
id: 'slack',
provider: 'slack',
enabled: slackKnowledgeEnabled && scopes.length > 0,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes,
instructions: 'Use Slack messages to update durable knowledge about projects, people, decisions, blockers, owners, deadlines, and status changes.',
filters: {
limit: 100,
maxBodyChars: 4000,
recentBackfillSeconds: 6 * 60 * 60,
},
})
toast.success('Slack knowledge source saved')
await refreshKnowledgeSources()
} catch (error) {
console.error('Failed to save Slack knowledge source:', error)
toast.error('Failed to save Slack knowledge source')
} finally {
setSlackKnowledgeSaving(false)
}
}, [parseSlackKnowledgeScopes, refreshKnowledgeSources, slackKnowledgeEnabled])
// Gmail (Composio)
const refreshGmailStatus = useCallback(async () => {
try {
@ -417,6 +669,8 @@ export function useConnectors(active: boolean) {
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
refreshSlackConfig()
refreshKnowledgeSources()
refreshSlackKnowledgeStatus()
if (useComposioForGoogle) {
refreshGmailStatus()
@ -461,7 +715,7 @@ export function useConnectors(active: boolean) {
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshSlackKnowledgeStatus, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
// Refresh when active or providers change
useEffect(() => {
@ -545,6 +799,11 @@ export function useConnectors(active: boolean) {
(status) => Boolean(status?.error)
)
// Whether the knowledge config has unsaved edits — drives Save button visibility.
const slackKnowledgeDirty =
slackKnowledgeEnabled !== slackKnowledgeSavedEnabled ||
slackKnowledgeChannels !== slackKnowledgeSavedChannels
return {
// OAuth providers
providers,
@ -587,9 +846,27 @@ export function useConnectors(active: boolean) {
setSlackPickerOpen,
slackDiscovering,
slackDiscoverError,
slackNeedsAuth,
slackAuthImporting,
slackCurlOpen,
setSlackCurlOpen,
slackCurlValue,
setSlackCurlValue,
slackCurlSubmitting,
slackSyncStatuses,
slackKnowledgeEnabled,
setSlackKnowledgeEnabled,
slackKnowledgeChannels,
setSlackKnowledgeChannels,
slackKnowledgeSaving,
slackKnowledgeDirty,
handleSlackEnable,
handleSlackImportDesktop,
handleSlackQuitAndImport,
handleSlackParseCurl,
handleSlackSaveWorkspaces,
handleSlackDisable,
handleSlackKnowledgeSave,
// Gmail (Composio)
useComposioForGoogle,

View file

@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { toast } from 'sonner';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
@ -21,8 +22,37 @@ const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.
// RMS threshold: system audio above this = "active" (speakers playing)
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
// Auto-stop after 2 minutes of silence (no transcript from Deepgram)
const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000;
// RMS threshold for "someone is talking" on either channel. Drives silence
// detection — kept a touch above the gate threshold so faint room noise on the
// mic doesn't read as speech and keep a finished recording alive.
const SPEECH_RMS_THRESHOLD = 0.01;
// Silence handling. "Silence" = no audio above SPEECH_RMS_THRESHOLD on EITHER
// the mic or the system-audio channel (i.e. nobody — local or remote — talking).
// - After SILENCE_NUDGE_MS we ask the user (toast) whether to stop.
// - After SILENCE_BACKSTOP_MS we stop unconditionally.
// - Once past the linked calendar event's end time we use the shorter
// POST_CALENDAR_END_SILENCE_MS, since a lull after the scheduled end is a
// strong signal the meeting is actually over.
const SILENCE_NUDGE_MS = 2 * 60 * 1000;
const SILENCE_BACKSTOP_MS = 5 * 60 * 1000;
const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000;
// How often the silence checker runs.
const SILENCE_CHECK_INTERVAL_MS = 5 * 1000;
// On macOS (ScreenCaptureKit) the system-audio track never fires "ended"/"mute"
// when the meeting ends, and its readyState stays "live" — only track.muted flips
// to true. But muted is ambiguous: it also goes true whenever no system audio is
// playing (a quiet but live meeting), so muted alone can't safely trigger a stop.
// See the poll in start() for how the muted signal is gated on the scheduled
// calendar end so a quiet stretch never cuts a live meeting short.
const TRACK_POLL_INTERVAL_MS = 3 * 1000;
const MUTE_POLLS_TO_STOP = 3;
// The ScreenCaptureKit quirk above is macOS-only; on Windows the track's "ended"
// event fires normally (handled by the listener in start()), so the poll below is
// gated to macOS.
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac');
// ---------------------------------------------------------------------------
// Headphone detection
@ -119,7 +149,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
const notePathRef = useRef<string>('');
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Silence detection: timestamp of the last speech-level audio on either
// channel, plus the interval that checks it. calendarEndMsRef holds the
// linked event's end time (null if none).
const lastAudioActivityRef = useRef<number>(0);
const silenceCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
const calendarEndMsRef = useRef<number | null>(null);
const nudgeToastIdRef = useRef<string | number | null>(null);
// On macOS (ScreenCaptureKit) the system-audio track doesn't reliably fire
// "ended"/"mute" when the meeting ends, so we poll its readyState/muted
// state instead.
const trackPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onAutoStopRef = useRef(onAutoStop);
onAutoStopRef.current = onAutoStop;
const dateRef = useRef<string>('');
@ -161,9 +201,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
clearTimeout(writeTimerRef.current);
writeTimerRef.current = null;
}
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = null;
if (silenceCheckRef.current) {
clearInterval(silenceCheckRef.current);
silenceCheckRef.current = null;
}
if (nudgeToastIdRef.current !== null) {
toast.dismiss(nudgeToastIdRef.current);
nudgeToastIdRef.current = null;
}
if (trackPollingRef.current) {
clearInterval(trackPollingRef.current);
trackPollingRef.current = null;
}
if (processorRef.current) {
processorRef.current.disconnect();
@ -279,13 +327,6 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
const transcript = data.channel.alternatives[0].transcript;
if (!transcript) return;
// Reset silence auto-stop timer on any transcript
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => {
console.log('[meeting] 2 minutes of silence — auto-stopping');
onAutoStopRef.current?.();
}, SILENCE_AUTO_STOP_MS);
const channelIndex = data.channel_index?.[0] ?? 0;
const isMic = channelIndex === 0;
@ -325,6 +366,56 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
const systemStream = systemResult.value;
systemStreamRef.current = systemStream;
// If the shared source goes away (user closes the call window / clicks
// "Stop sharing"), the track fires "ended" — treat that as the meeting
// ending and stop. Our own cleanup() calls track.stop(), which does NOT
// fire "ended", so this won't double-trigger on a manual stop.
systemStream.getAudioTracks().forEach(track => {
track.addEventListener('ended', () => {
console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping');
onAutoStopRef.current?.();
});
});
// On macOS the system-audio track's "ended"/"mute" events don't fire when
// the meeting ends, so poll its state instead. (On Windows the "ended"
// listener above already covers this, so the poll is macOS-only.)
//
// - readyState === 'ended' is unambiguous (the source is gone) → stop now.
// It never actually fires on macOS (readyState stays 'live'); it's just
// a safety net should polling ever observe the track ending.
// - muted is ambiguous on macOS: it flips true both when the meeting ends
// AND when nothing is playing system audio (a quiet but live meeting).
// So we only treat sustained mute as "meeting over" once we're past the
// linked event's scheduled end — a dead audio track after the meeting
// was due to finish is a strong signal. With no calendar event, or
// before the scheduled end, we DON'T hard-stop on mute; the silence
// checker's nudge + backstop handles it, so a quiet stretch can never
// silently cut a live meeting short.
const pollTrack = systemStream.getAudioTracks()[0];
if (isMac && pollTrack) {
let mutedPolls = 0;
if (trackPollingRef.current) clearInterval(trackPollingRef.current);
trackPollingRef.current = setInterval(() => {
if (pollTrack.readyState === 'ended') {
console.log('[meeting] system-audio track ended (poll) — auto-stopping');
onAutoStopRef.current?.();
return;
}
if (pollTrack.muted) {
mutedPolls++;
const endMs = calendarEndMsRef.current;
const pastCalendarEnd = endMs != null && Date.now() > endMs;
if (pastCalendarEnd && mutedPolls >= MUTE_POLLS_TO_STOP) {
console.log('[meeting] system-audio track muted past scheduled end (poll) — auto-stopping');
onAutoStopRef.current?.();
}
} else {
mutedPolls = 0;
}
}, TRACK_POLL_INTERVAL_MS);
}
// ----- Audio pipeline -----
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioCtxRef.current = audioCtx;
@ -345,24 +436,33 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
const micRaw = e.inputBuffer.getChannelData(0);
const sysRaw = e.inputBuffer.getChannelData(1);
// RMS of each channel, computed once per frame and reused for
// silence detection and gating the mic in speaker mode.
let micSum = 0;
for (let i = 0; i < micRaw.length; i++) micSum += micRaw[i] * micRaw[i];
const micRms = Math.sqrt(micSum / micRaw.length);
let sysSum = 0;
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
const sysRms = Math.sqrt(sysSum / sysRaw.length);
// Reset the silence clock whenever EITHER channel has speech-level
// audio. Uses the raw mic (pre-gating) so the user's own voice counts
// even in speaker mode where the outgoing mic gets muted.
if (micRms > SPEECH_RMS_THRESHOLD || sysRms > SPEECH_RMS_THRESHOLD) {
lastAudioActivityRef.current = Date.now();
}
// Mode 1 (headphones): pass both streams through unmodified
// Mode 2 (speakers): gate/mute mic when system audio is active
let micOut: Float32Array;
if (usingHeadphones) {
micOut = micRaw;
} else if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
// System audio is playing — mute mic to prevent bleed
micOut = new Float32Array(micRaw.length); // all zeros
} else {
// Compute system audio RMS to detect activity
let sysSum = 0;
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
const sysRms = Math.sqrt(sysSum / sysRaw.length);
if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
// System audio is playing — mute mic to prevent bleed
micOut = new Float32Array(micRaw.length); // all zeros
} else {
// System audio is silent — pass mic through
micOut = micRaw;
}
// System audio is silent — pass mic through
micOut = micRaw;
}
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
@ -391,6 +491,12 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
notePathRef.current = notePath;
calendarEventRef.current = calendarEvent;
// Parse the linked event's end time (timed events only) so the silence
// window can shorten once the meeting is past its scheduled end.
const calEndMs = calendarEvent?.end?.dateTime ? Date.parse(calendarEvent.end.dateTime) : NaN;
calendarEndMsRef.current = Number.isFinite(calEndMs) ? calEndMs : null;
const initialContent = formatTranscript([], dateStr, calendarEvent);
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
@ -398,6 +504,45 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
opts: { encoding: 'utf8', mkdirp: true },
});
// Arm silence detection. Initialise the activity clock to "now" so the
// checker is live from the very start of recording — a session that
// never captures any audio still auto-stops at the backstop instead of
// running forever.
lastAudioActivityRef.current = Date.now();
if (silenceCheckRef.current) clearInterval(silenceCheckRef.current);
silenceCheckRef.current = setInterval(() => {
const silentMs = Date.now() - lastAudioActivityRef.current;
const endMs = calendarEndMsRef.current;
const pastCalendarEnd = endMs != null && Date.now() > endMs;
const hardStopMs = pastCalendarEnd ? POST_CALENDAR_END_SILENCE_MS : SILENCE_BACKSTOP_MS;
if (silentMs >= hardStopMs) {
console.log(`[meeting] ${Math.round(silentMs / 1000)}s of silence${pastCalendarEnd ? ' (past scheduled end)' : ''} — auto-stopping`);
onAutoStopRef.current?.();
return;
}
if (silentMs >= SILENCE_NUDGE_MS) {
// Ask once; the toast persists until dismissed or acted on. Past
// the scheduled end we skip straight to the hard stop above, so
// the nudge only ever shows for an in-progress meeting.
if (nudgeToastIdRef.current === null) {
nudgeToastIdRef.current = toast('Still in a meeting?', {
description: "It's been quiet for a couple of minutes.",
duration: Infinity,
action: {
label: 'Stop recording',
onClick: () => { onAutoStopRef.current?.(); },
},
});
}
} else if (nudgeToastIdRef.current !== null) {
// Audio resumed before the backstop — retract the nudge.
toast.dismiss(nudgeToastIdRef.current);
nudgeToastIdRef.current = null;
}
}, SILENCE_CHECK_INTERVAL_MS);
setState('recording');
return notePath;
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);

View file

@ -20,6 +20,17 @@ const DEEPGRAM_PARAMS = new URLSearchParams({
});
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
// Cap on retained per-frame amplitude samples (~64ms/frame ⇒ ~5 min of history).
// The waveform only ever displays the most recent window, so older samples are dropped.
const MAX_AUDIO_LEVELS = 4800;
// Auto-gain for the waveform: each frame's amplitude is stored normalized against a
// running peak (instant attack, slow release) so bar heights track the *relative*
// loudness of the voice accurately regardless of mic/OS input gain. MIN_PEAK is a
// floor so near-silence doesn't get amplified up into tall bars.
const PEAK_DECAY = 0.97;
const MIN_PEAK = 0.02;
// Cache auth details so we don't need IPC round-trips on every mic click
let cachedAuth: { type: 'rowboat'; url: string; token: string } | { type: 'local'; apiKey: string } | null = null;
@ -35,6 +46,12 @@ export function useVoiceMode() {
const interimRef = useRef('');
// Buffer audio chunks captured before the WebSocket is ready
const audioBufferRef = useRef<ArrayBuffer[]>([]);
// Rolling history of per-frame mic amplitude (auto-gained to 0..1), oldest first.
// Drives the live waveform — the UI reads this via requestAnimationFrame so
// amplitude updates never re-render the rest of the tree.
const audioLevelsRef = useRef<number[]>([]);
// Running peak amplitude for the waveform auto-gain (see PEAK_DECAY/MIN_PEAK).
const audioPeakRef = useRef(0);
// Refresh cached auth details (called on warmup, not on mic click)
const refreshAuth = useCallback(async () => {
@ -132,6 +149,8 @@ export function useVoiceMode() {
wsRef.current = null;
}
audioBufferRef.current = [];
audioLevelsRef.current = [];
audioPeakRef.current = 0;
setInterimText('');
transcriptBufferRef.current = '';
interimRef.current = '';
@ -145,12 +164,28 @@ export function useVoiceMode() {
interimRef.current = '';
setInterimText('');
audioBufferRef.current = [];
audioLevelsRef.current = [];
audioPeakRef.current = 0;
// Show listening immediately — don't wait for WebSocket
setState('listening');
analytics.voiceInputStarted();
posthog.people.set_once({ has_used_voice: true });
// Settle the OS-level microphone permission before capturing. On the
// first-ever use (macOS) the permission is 'not-determined'; calling
// getUserMedia directly would reject while the native prompt is up,
// making the first mic click silently do nothing. Resolving it here
// lets this same click proceed once the user grants access.
const mic = await window.ipc
.invoke('voice:ensureMicAccess', null)
.catch(() => ({ granted: true }));
if (!mic.granted) {
console.error('Microphone access denied');
stopAudioCapture();
return;
}
// Kick off mic + WebSocket in parallel, don't await WebSocket
const [stream] = await Promise.all([
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
@ -161,7 +196,10 @@ export function useVoiceMode() {
]);
if (!stream) {
setState('idle');
// connectWs() may have already opened a socket — tear everything
// down (close WS, reset buffers, state) rather than only resetting
// state, which would leak the socket into the next attempt.
stopAudioCapture();
return;
}
@ -171,15 +209,32 @@ export function useVoiceMode() {
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioCtxRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const processor = audioCtx.createScriptProcessor(2048, 1, 1);
// 1024-sample frames (~64ms at 16kHz) — smaller than the usual 2048 so the
// waveform gets ~16 amplitude updates/sec, making bars appear faster and
// flow more smoothly. Still a comfortable chunk size for Deepgram streaming.
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
processorRef.current = processor;
processor.onaudioprocess = (e) => {
const float32 = e.inputBuffer.getChannelData(0);
const int16 = new Int16Array(float32.length);
let sumSquares = 0;
for (let i = 0; i < float32.length; i++) {
const s = Math.max(-1, Math.min(1, float32[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
sumSquares += s * s;
}
// Record this frame's loudness for the live waveform, auto-gained against
// a running peak so bar heights accurately reflect the voice's dynamics.
// Instant attack (a louder frame raises the peak immediately), slow
// release (PEAK_DECAY), floored at MIN_PEAK so silence stays flat.
const rms = Math.sqrt(sumSquares / float32.length);
const peak = Math.max(rms, audioPeakRef.current * PEAK_DECAY, MIN_PEAK);
audioPeakRef.current = peak;
const levels = audioLevelsRef.current;
levels.push(rms / peak);
if (levels.length > MAX_AUDIO_LEVELS) {
levels.splice(0, levels.length - MAX_AUDIO_LEVELS);
}
const buffer = int16.buffer;
if (wsRef.current?.readyState === WebSocket.OPEN) {
@ -192,7 +247,7 @@ export function useVoiceMode() {
source.connect(processor);
processor.connect(audioCtx.destination);
}, [state, connectWs]);
}, [state, connectWs, stopAudioCapture]);
/** Stop recording and return the full transcript (finalized + any current interim) */
const submit = useCallback((): string => {
@ -215,5 +270,5 @@ export function useVoiceMode() {
refreshAuth().catch(() => {});
}, [refreshAuth]);
return { state, interimText, start, submit, cancel, warmup };
return { state, interimText, audioLevelsRef, start, submit, cancel, warmup };
}

View file

@ -1,7 +1,8 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
export interface MessageAttachment {
path: string
@ -27,6 +28,9 @@ export interface ToolCall {
streamingOutput?: string
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
codeRunEvents?: CodeRunEvent[]
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
}
export interface ErrorMessage {
@ -46,6 +50,7 @@ export type ChatTabViewState = {
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
}
export type ChatViewportAnchorState = {
@ -60,6 +65,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(),
permissionResponses: new Map(),
autoPermissionDecisions: new Map(),
})
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
@ -517,41 +523,9 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
* For builtin tools, returns a static friendly name (e.g., "Reading file").
* Falls back to the raw tool name if no mapping exists.
*/
// Phrases shown while a code-mode task is running. They advance over time (5s
// each) to read as progress, then hold on the last one until the task finishes.
const CODE_MODE_RUNNING_LABELS = [
'Working on the task…',
'Inspecting the project…',
'Digging into the code…',
'Figuring it out…',
'Making the changes…',
'Wiring things up…',
'Putting it together…',
]
const CODE_MODE_LABEL_INTERVAL_MS = 5000
// Detect acpx coding-agent invocations (code mode) and produce a status-aware
// label, e.g. "Working on the task…" → "Completed the task".
export const getCodeModeCommandLabel = (tool: ToolCall): string | null => {
if (tool.name !== 'executeCommand') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const command = typeof input?.command === 'string' ? input.command : ''
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/)
if (!match) return null
if (tool.status === 'error') return `Couldn't complete the task`
if (tool.status === 'completed') return `Completed the task`
// Advance through the phrases from the tool's start, holding on the last.
const elapsed = Math.max(0, Date.now() - tool.timestamp)
const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS)
const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1)
return CODE_MODE_RUNNING_LABELS[idx]
}
export const getToolDisplayName = (tool: ToolCall): string => {
const browserLabel = getBrowserControlLabel(tool)
if (browserLabel) return browserLabel
const codeModeLabel = getCodeModeCommandLabel(tool)
if (codeModeLabel) return codeModeLabel
const composioData = getComposioActionCardData(tool)
if (composioData) return composioData.label
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
@ -632,6 +606,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
if (!isToolCall(item)) return false
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
if (getWebSearchCardData(item)) return false
if (getComposioConnectCardData(item)) return false
if (getAppActionCardData(item)) return false

View file

@ -11,6 +11,9 @@
"test:watch": "vitest"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
"@agentclientprotocol/codex-acp": "^0.0.44",
"@agentclientprotocol/sdk": "^0.22.1",
"@ai-sdk/anthropic": "^2.0.63",
"@ai-sdk/google": "^2.0.53",
"@ai-sdk/openai": "^2.0.91",

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

@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
import container from "../di/container.js";
import { notifyIfEnabled } from "../application/notification/notifier.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { resolveProviderConfig } from "../models/defaults.js";
@ -36,6 +37,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
@ -376,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime {
type: "run-processing-start",
subflow: [],
});
let totalEvents = 0;
while (true) {
// Check for abort before each iteration
if (signal.aborted) {
@ -416,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime {
throw error;
}
totalEvents += eventCount;
// if no events, break
if (!eventCount) {
break;
@ -432,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime {
};
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
await this.bus.publish(stoppedEvent);
} else if (totalEvents > 0) {
// The run reached a natural stopping point and actually did
// something this cycle. Notify "chat completion" — unless it
// paused on a permission request, which surfaces its own
// notification (distinguish by inspecting the final state).
const finalRun = await this.runsRepo.fetch(runId);
if (finalRun) {
const finalState = new AgentState();
for (const event of finalRun.log) {
finalState.ingest(event);
}
if (finalState.getPendingPermissions().length === 0) {
void notifyIfEnabled("chat_completion", {
title: "Response ready",
message: "Your agent finished responding.",
link: `rowboat://open?type=chat&runId=${runId}`,
actionLabel: "Open",
onlyWhenBackground: true,
});
}
}
}
} catch (error) {
console.error(`Run ${runId} failed:`, error);
@ -901,6 +926,7 @@ export class AgentState {
agentName: string | null = null;
runModel: string | null = null;
runProvider: string | null = null;
permissionMode: "manual" | "auto" = "manual";
runUseCase: UseCase | null = null;
runSubUseCase: string | null = null;
messages: z.infer<typeof MessageList> = [];
@ -912,6 +938,8 @@ export class AgentState {
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {};
autoAllowedToolCalls: Record<string, { reason: string }> = {};
autoDeniedToolCalls: Record<string, { reason: string }> = {};
sessionAllowedCommands: Set<string> = new Set();
sessionAllowedFileAccess: FileAccessGrant[] = [];
@ -1019,6 +1047,7 @@ export class AgentState {
this.agentName = event.agentName;
this.runModel = event.model;
this.runProvider = event.provider;
this.permissionMode = event.permissionMode ?? "manual";
this.runUseCase = event.useCase ?? null;
this.runSubUseCase = event.subUseCase ?? null;
break;
@ -1031,6 +1060,7 @@ export class AgentState {
this.subflowStates[event.toolCallId].agentName = event.agentName;
this.subflowStates[event.toolCallId].runModel = this.runModel;
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
this.subflowStates[event.toolCallId].permissionMode = this.permissionMode;
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
break;
@ -1081,10 +1111,22 @@ export class AgentState {
break;
case "deny":
this.deniedToolCallIds[event.toolCallId] = true;
delete this.autoDeniedToolCalls[event.toolCallId];
break;
}
delete this.pendingToolPermissionRequests[event.toolCallId];
break;
case "tool-permission-auto-decision":
switch (event.decision) {
case "allow":
this.allowedToolCallIds[event.toolCallId] = true;
this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason };
break;
case "deny":
this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason };
break;
}
break;
case "ask-human-request":
this.pendingAskHumanRequests[event.toolCallId] = event;
break;
@ -1163,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 }
@ -1190,13 +1234,19 @@ export async function* streamAgent({
// if tool has been denied, deny
if (state.deniedToolCallIds[toolCallId]) {
_logger.log('returning denied tool message, reason: tool has been denied');
const autoDenied = state.autoDeniedToolCalls[toolCallId];
yield* processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
message: {
role: "tool",
content: "Unable to execute this tool: Permission was denied.",
content: autoDenied
? JSON.stringify({
success: false,
error: `Auto-permission denied: ${autoDenied.reason}`,
})
: "Unable to execute this tool: Permission was denied.",
toolCallId: toolCallId,
toolName: toolCall.toolName,
},
@ -1255,6 +1305,9 @@ export async function* streamAgent({
signal,
abortRegistry,
publish: (event) => bus.publish(event),
codeMode,
codeCwd,
codePolicy,
});
}
} catch (error) {
@ -1314,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;
}
@ -1402,44 +1457,19 @@ Do not announce the work directory unless it's relevant. Just use it.`;
if (codeMode) {
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
const otherAgent = codeMode === 'claude' ? 'codex' : 'claude';
const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code';
// Deterministic, per-chat session name so the coding agent keeps
// context across the user's requests within this chat. Reusing the
// same -s <name> resumes the session; the first call creates it.
const sessionName = `rowboat-${runId}`;
instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn.
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
**The user can override the agent at any time, two ways:**
1. By toggling the chip in the composer (preferred).
2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness.
The chip is the single source of truth for which agent runs:
- Do NOT carry over a different agent from earlier in this thread even if a previous run used the other agent, use **${agentDisplay}** now.
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
**Persistent session for this chat session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create).
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`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.
**1. First coding action in this chat ensure the session exists:**
\`\`\`
npx acpx@latest --approve-all --cwd <workdir> <agent> sessions ensure --name ${sessionName}
\`\`\`
(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.)
**2. Then run the prompt:**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
\`\`\`
**3. Every follow-up coding request in this chat reuse the same session (do NOT create again):**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
\`\`\`
Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch.
Where \`<agent>\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets).
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.
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
}
@ -1493,6 +1523,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
// if there were any ask-human calls, emit those events
if (message.content instanceof Array) {
const permissionCandidates: AutoPermissionCandidate[] = [];
for (const part of message.content) {
if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName];
@ -1518,14 +1549,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
state.sessionAllowedFileAccess,
);
if (permission) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
permission,
subflow: [],
});
permissionCandidates.push({ toolCall: part, permission });
}
if (underlyingTool.type === "agent" && underlyingTool.name) {
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
@ -1549,6 +1573,100 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
}
}
}
if (permissionCandidates.length > 0) {
// Permission prompts block the run, so they surface even when the
// app is focused (no onlyWhenBackground gate).
const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => {
void notifyIfEnabled("agent_permission", {
title: "Permission needed",
message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`,
link: `rowboat://open?type=chat&runId=${runId}`,
actionLabel: "Review",
});
};
if (state.permissionMode === "auto") {
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
try {
const decisions = await classifyToolPermissions({
runId,
agentName: state.agentName,
messages: convertFromMessages(state.messages),
candidates: permissionCandidates,
useCase: state.runUseCase ?? "copilot_chat",
subUseCase: state.runSubUseCase,
});
decisionsByToolCallId = new Map(decisions.map((decision) => [
decision.toolCallId,
{ decision: decision.decision, reason: decision.reason },
]));
} catch (error) {
loopLogger.log(
'auto-permission classifier failed:',
error instanceof Error ? error.message : String(error),
);
}
for (const candidate of permissionCandidates) {
const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId);
if (!decision) {
loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
continue;
}
loopLogger.log(
'emitting tool-permission-auto-decision, toolCallId:',
candidate.toolCall.toolCallId,
'decision:',
decision.decision,
);
yield* processEvent({
runId,
type: "tool-permission-auto-decision",
toolCallId: candidate.toolCall.toolCallId,
toolCall: candidate.toolCall,
permission: candidate.permission,
decision: decision.decision,
reason: decision.reason,
subflow: [],
});
if (decision.decision === "deny") {
loopLogger.log(
'auto-permission denied, falling back to prompt:',
candidate.toolCall.toolCallId,
);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
}
}
} else {
for (const candidate of permissionCandidates) {
loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
}
}
}
}
}
}

View file

@ -14,7 +14,7 @@ export async function identifyIfSignedIn(): Promise<void> {
if (!billing.userId) return;
identify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
} catch (err) {

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

@ -5,6 +5,8 @@ import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ISlackConfigRepo } from "../../slack/repo.js";
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@ -12,7 +14,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(): Promise<string> {
async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<string> {
if (!(await isComposioConfigured())) {
return '';
}
@ -22,28 +24,54 @@ async function getComposioToolsPrompt(): Promise<string> {
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
// Slack is connected natively, so exclude it from the Composio catch-all.
const slackException = slackConnected
? ` Exception: **Slack is connected natively** — use the \`slack\` skill for Slack, not Composio.`
: '';
return `
## Composio Integrations
${connectedSection}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}
`;
}
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = ''): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
// When Slack is connected natively (desktop/cURL auth, not Composio), keep it
// out of the Composio routing examples so the Copilot doesn't try to connect
// it through Composio and wrongly report it as unavailable.
const composioServiceExamples = slackConnected
? 'Gmail, GitHub, LinkedIn, Notion, Google Sheets, Jira, etc.'
: 'Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.';
const thirdPartyBlock = composioEnabled
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
: '';
// Slack is connected directly in Rowboat (agent-slack CLI), independent of
// Composio. Route every Slack request to the native \`slack\` skill so the
// Copilot never claims Slack isn't connected or sends it through Composio.
const slackChannelsLine = slackChannelsHint
? ` The user has selected these Slack channels to follow: ${slackChannelsHint}. For broad "what's on my Slack / catch me up / anything new" requests, query THESE channels directly with \`agent-slack message list "#channel" --workspace <url> --oldest <unix-seconds> --limit 100 --resolve-users\` (use \`--oldest\`/\`--latest\` to scope to today/yesterday). Do NOT rely on \`search messages\` or \`unreads\` to answer catch-up questions — they frequently return empty with desktop-imported auth even when channels have messages; direct \`message list\` is authoritative.`
: '';
const slackBlock = slackConnected
? `\n**Slack (connected):** Slack is connected directly in Rowboat (via the agent-slack CLI, not Composio). For ANY Slack request — summarizing or reading messages, catching up on channels or DMs, searching, listing users, or sending a message — your FIRST action MUST be \`loadSkill('slack')\`, then use the \`agent-slack\` commands it documents via \`executeCommand\` (the selected workspaces are in \`config/slack.json\`). NEVER tell the user Slack isn't connected, and NEVER route Slack through the \`composio-integration\` skill.${slackChannelsLine}\n`
: '';
const slackToolPriority = slackConnected
? ` For Slack specifically, load the \`slack\` skill and use the agent-slack CLI — Slack is connected natively, not via Composio.`
: '';
const toolPriority = composioEnabled
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
? `For third-party services (GitHub, Gmail, etc.), load the \`composio-integration\` skill.${slackToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}`;
const slackToolsLine = composioEnabled
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
@ -76,7 +104,7 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
${thirdPartyBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
@ -130,6 +158,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
## The Knowledge Graph
The knowledge graph is the user's **Brain**. If the user says "my brain", "the brain", "look into your brain", "check my brain", "Brain", or similar, they mean the knowledge graph stored in \`knowledge/\`. Treat "Brain" and "knowledge graph" as the same thing.
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
@ -332,14 +362,41 @@ export async function buildCopilotInstructions(): Promise<string> {
} catch {
// repo unavailable — default to disabled
}
let slackConnected = false;
let slackChannelsHint = '';
try {
const slackRepo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const slackConfig = await slackRepo.getConfig();
slackConnected = slackConfig.enabled && slackConfig.workspaces.length > 0;
} catch {
// repo unavailable — default to not connected
}
if (slackConnected) {
try {
// Surface the channels the user selected for sync so the Copilot
// queries those directly instead of relying on workspace-wide search.
const slackSource = knowledgeSourcesRepo.getConfig().sources
.find(source => source.provider === 'slack' && source.enabled);
const channels = (slackSource?.scopes ?? []).filter(scope => scope.type === 'channel');
slackChannelsHint = channels
.map(scope => {
const raw = scope.name || scope.id;
const display = raw.startsWith('#') ? raw : `#${raw}`;
return scope.workspaceUrl ? `${display} (${scope.workspaceUrl})` : display;
})
.join(', ');
} catch {
// knowledge sources unavailable — fall back to no channel hint
}
}
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const composioPrompt = await getComposioToolsPrompt();
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint);
const composioPrompt = await getComposioToolsPrompt(slackConnected);
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
: baseInstructions;

View file

@ -10,7 +10,9 @@ export const skill = String.raw`
A *background task* is a persistent agent the user configures once and the framework keeps firing on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at \`bg-tasks/<slug>/\` and owns two artifacts:
- \`task.yaml\` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth.
- \`index.md\` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run.
- \`index.md\` — the agent-owned body (a note). The runtime never writes here; the bg-task agent does, each run.
For **visual** output a dashboard, a styled report, a metrics table with conditional colors, a chart the agent may instead write a self-contained \`index.html\`, which the task view renders full-screen in a sandboxed iframe with CSS and layout preserved. The agent picks the format per run from the instructions; you don't set it, but when the ask is inherently visual, say so in the instructions (e.g. "…rendered as a styled HTML dashboard") so the agent leans that way.
A task is one of two shapes the agent decides per run from the verbs in \`instructions\`:

View file

@ -5,6 +5,8 @@ Use this skill whenever the user asks you to write code, build a project, create
Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself.
All coding work runs through the **\`code_agent_run\`** tool. It launches the selected on-device coding agent (Claude Code / Codex), streams its tool calls, file diffs, and plan into the chat, and surfaces any action needing approval as an inline permission card. One persistent session is kept per chat, so follow-up requests resume with full context automatically.
---
## STEP 1 MANDATORY FIRST ACTION
@ -39,96 +41,52 @@ This is non-negotiable. The user gets clickable buttons. Free-text "which agent?
---
## STEP 2 Resolve workdir, confirm, execute
## STEP 2 Resolve workdir, then run
**Resolve the workdir** (in this priority order):
1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`).
2. The path from a "# User Work Directory" block in your context.
3. Ask once in plain text: "Which folder should I work in?"
**State your intent in one line, then execute immediately do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
**Pick the agent** (\`claude\` or \`codex\`): use the agent from the "# Code Mode (Active)" block (the composer chip) / the Step 1 choice. The chip is authoritative — do NOT carry over a different agent from earlier in this thread, and do NOT switch on an in-chat text request ("use codex"); tell the user to toggle the chip instead.
**State your intent in one line, then call the tool immediately do NOT wait for a "yes".** The tool's own permission cards are the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
and then immediately make the \`executeCommand\` call in the same turn.
**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context.
Pick \`<agent>\` (\`claude\` or \`codex\`) by, in priority order:
- An explicit in-chat override from the user this turn ("use codex", "switch to claude") honor it.
- The agent chosen in Step 1 / the "# Code Mode (Active)" block.
Pick \`<session-name>\` — **stable for this whole chat**:
- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), use that exact name.
- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** never a new name each time.
**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt:
**1. First coding action in this chat ensure the session exists:**
and then immediately call:
\`\`\`
npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>
code_agent_run({
agent: "<claude|codex>",
cwd: "<resolved absolute folder>",
prompt: "<clear, self-contained coding instruction>"
})
\`\`\`
(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.)
**2. Then run the prompt:**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
\`\`\`
**3. Every follow-up coding request in this chat reuse the same session (do NOT create again):**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
\`\`\`
**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins.
Do NOT use \`exec\` — it is one-shot and forgets everything.
Concrete example:
\`\`\`
# First coding message in the chat ensure the session, then prompt:
npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space."
# Follow-up in the same chat reuse the session, no create:
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings."
\`\`\`
### Critical: flag order
\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name <name>\` and \`-s <session-name>\` come AFTER the agent name:
- Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\`
- Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (will fail)
### Writing good prompts for the agent
**Writing good prompts for the agent:**
- Be specific: file names, function signatures, expected behavior.
- Mention constraints (language, framework, style).
- Expand short user requests into clear, actionable prompts.
- Expand short user requests into clear, actionable instructions.
**Follow-ups:** for every later coding request in this chat, just call \`code_agent_run\` again with the same \`cwd\` and the chip's current agent. The session resumes automatically — do NOT start over or re-explain prior context.
---
## STEP 3 Report results
After the command finishes:
- Pass through the coding agent's summary as-is. Do not rewrite.
After \`code_agent_run\` returns:
- Pass through the agent's \`summary\` as-is. Do not rewrite it.
- Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.)
- Only add your own explanation if the command failed (non-zero exit):
- Exit code 5 permissions were denied (shouldn't happen with \`--approve-all\`; flag it).
- Exit code 4 / "No acpx session found" the \`-s <session-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>\`, then retry the prompt. (\`-s\` only resumes; it never creates.)
- "command not found" / agent not installed, or an auth/sign-in error the agent isn't set up. Tell the user to install or sign in to the agent via **Settings Code Mode**, where Rowboat shows the install and sign-in status.
- Only add your own explanation if it failed:
- \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**.
- \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue.
---
## Once delegating: delegate fully
After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
After Step 2 fires, delegate ALL related coding tasks for this turn to \`code_agent_run\` — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
## Prerequisites (informational)

View file

@ -202,6 +202,7 @@ Subject: Re: {original_subject}
**Drafting Guidelines:**
- Draft ONE email - do not offer multiple versions or options unless explicitly asked
- Be concise and professional
- If you include a sign-off name, use only the user's first name, never their full name
- For scheduling: propose specific times based on calendar availability
- For inquiries: answer directly or indicate what info is needed
- Reference any relevant context from memory naturally - show you remember past interactions

View file

@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [
{
id: "code-with-agents",
title: "Code with Agents",
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.",
content: codeWithAgentsSkill,
},
{

View file

@ -5,12 +5,27 @@ You interact with Slack by running **agent-slack** commands through \`executeCom
---
## 1. Check Connection
## 1. Check Connection & Selected Channels
Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
**Which channels the user follows:** The user selects specific channels to sync in \`config/knowledge_sources.json\`. Read that file and find the source with \`"provider": "slack"\`; its \`scopes\` array (entries with \`"type": "channel"\`) lists the selected channels (each has a \`name\` like \`#general\` and an optional \`workspaceUrl\`). For broad "what's on my Slack / catch me up / anything new" requests where the user did NOT name a channel, query these selected channels directly — do not guess or run workspace-wide search.
---
## 1b. Catching Up ("what's new", "today", "yesterday")
For catch-up questions, list recent messages from each selected channel and filter by time with \`--oldest\` / \`--latest\` (Unix-epoch seconds):
\`\`\`
# Everything in #general since the start of today (compute the epoch for 00:00 local)
agent-slack message list "#general" --workspace https://team.slack.com --oldest 1718668800 --limit 100 --resolve-users
\`\`\`
**Do NOT use \`agent-slack unreads\` or \`agent-slack search messages\` to answer catch-up questions.** With desktop-imported auth those endpoints frequently return empty even when channels clearly have messages. Direct \`message list\` against the selected channels is the authoritative source. Run one \`message list\` per selected channel (batch them in a single \`executeCommand\` with \`;\` separators), then summarize across channels. Always pass \`--resolve-users\` so author names are readable.
---
## 2. Core Commands
@ -41,6 +56,8 @@ agent-slack message react remove "<target>" <emoji> --ts <ts>
### Search
Note: search is best for finding a *specific* message by keyword. It can return empty under desktop-imported auth, so never conclude "there's nothing on Slack" from an empty search fall back to \`message list\` on the selected channels (see section 1b).
\`\`\`
agent-slack search messages "query text" --limit 20
agent-slack search messages "query" --channel "#channel-name" --user "@username"

View file

@ -1,8 +1,9 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as os from "os";
import * as fs from "fs/promises";
import { existsSync, readFileSync } from "fs";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
@ -16,6 +17,12 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
import * as gitService from "../../code-mode/git/service.js";
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
// descriptions; only `triggers` gets a tighter contextual override (the
@ -28,6 +35,9 @@ const CreateBackgroundTaskInput = BackgroundTaskSchema.pick({
provider: true,
}).extend({
triggers: TriggersSchema.optional().describe('All three sub-fields (cronExpr, windows, eventMatchCriteria) are independently optional — mix freely. No triggers at all = manual-only (user clicks Run).'),
projectDir: z.string().optional().describe(
"Set this ONLY when the user wants the task to WRITE CODE. An absolute path (or ~/…) to a LOCAL GIT REPOSITORY with at least one commit. It turns this into a *coding task*: each run scans the trigger source for actionable items and implements them autonomously in isolated git worktrees off this repo — never touching the user's checkout. Extract the directory from the user's request (e.g. 'use ~/Work/space/test as the work directory'). Omit for ordinary output/action tasks.",
),
});
const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
@ -40,7 +50,43 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
}).partial().extend({
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
});
// Turn a user-supplied directory into a registered code project id. Reuses the
// same idempotent registry the Code-section picker writes to (add() validates the
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
// still gets created and the copilot can tell the user what to fix.
function expandHome(p: string): string {
const t = p.trim();
if (t === '~') return os.homedir();
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
return t;
}
async function resolveCodeProject(dirPath: string): Promise<
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
> {
const abs = path.resolve(expandHome(dirPath));
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
try {
project = await projectsRepo.add(abs);
} catch (err) {
return { ok: false, error: `Could not use '${dirPath}' as a code directory: ${err instanceof Error ? err.message : String(err)}` };
}
// Worktree isolation needs a real git repo with at least one commit
// (codeSessionService.create throws otherwise). Surface it now as a soft
// warning rather than letting the next run fail silently.
let warning: string | undefined;
try {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo) warning = `${project.path} is not a git repository yet — run \`git init\` and make a commit, or the coding sessions will fail.`;
else if (!info.hasCommits) warning = `${project.path} has no commits yet — make an initial commit, or the coding sessions will fail.`;
} catch { /* best effort — worktree creation will surface it later */ }
return { ok: true, projectId: project.id, path: project.path, ...(warning ? { warning } : {}) };
}
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
@ -90,69 +136,6 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
'.tiff': 'image/tiff',
};
// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE
// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL).
// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe
// from the npm-shim layout and inject it via env so the bridge can spawn it.
function resolveClaudeExeOnWindows(): string | undefined {
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
// includes them, which would otherwise leave us unable to find claude.exe and
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
const home = process.env.USERPROFILE ?? '';
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const knownDirs = [
appData && path.join(appData, 'npm'),
localAppData && path.join(localAppData, 'npm'),
appData && path.join(appData, 'pnpm'),
localAppData && path.join(localAppData, 'pnpm'),
home && path.join(home, '.volta', 'bin'),
path.join(programFiles, 'nodejs'),
].filter(Boolean) as string[];
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
const seen = new Set<string>();
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
const key = d.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
for (const dir of candidates) {
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
if (existsSync(exeFromLayout)) return exeFromLayout;
// Otherwise parse the claude.cmd shim for the real exe path.
const cmdPath = path.join(dir, 'claude.cmd');
if (!existsSync(cmdPath)) continue;
try {
const content = readFileSync(cmdPath, 'utf-8');
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
if (relMatch) {
const resolved = path.join(dir, relMatch[1]);
if (existsSync(resolved)) return resolved;
}
} catch {
// ignore shim parse failures
}
}
return undefined;
}
function envForCommand(command: string): NodeJS.ProcessEnv | undefined {
if (process.platform !== 'win32') return undefined;
if (!/\bacpx\b/.test(command)) return undefined;
if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined;
const exe = resolveClaudeExeOnWindows();
if (!exe) return undefined;
return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe };
}
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
@ -800,6 +783,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
try {
const rootDir = path.resolve(WorkDir);
const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir;
// Make `agent-slack` resolvable for skill-authored shell
// commands; the shim forwards to the bundled CLI.
const env = agentSlackShimEnv(path.join(rootDir, 'bin'));
// TODO: Re-enable this check
// const rootPrefix = rootDir.endsWith(path.sep)
@ -814,14 +800,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// };
// }
const envOverride = envForCommand(command);
// Use abortable version when we have a signal
if (ctx?.signal) {
const { promise, process: proc } = executeCommandAbortable(command, {
cwd: workingDir,
env,
signal: ctx.signal,
env: envOverride,
onData: (chunk: string) => {
ctx.publish({
runId: ctx.runId,
@ -851,7 +835,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
// Fallback to original for backward compatibility
const result = await executeCommand(command, { cwd: workingDir, env: envOverride });
const result = await executeCommand(command, { cwd: workingDir, env });
return {
success: result.exitCode === 0,
@ -871,6 +855,112 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
code_agent_run: {
description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.',
inputSchema: z.object({
agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'),
cwd: z.string().describe('Absolute path to the working directory / project folder the agent should operate in.'),
prompt: z.string().describe('The full, self-contained coding instruction for the agent (file names, expected behavior, constraints).'),
}),
execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => {
if (!ctx) {
return { success: false, message: 'code_agent_run requires run context (runId / streaming).' };
}
// The composer chip is the source of truth for the agent. The model's `agent`
// argument is only a fallback for the ask-human flow (code mode not active, no
// 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: the session's (Code section) wins, else global settings,
// else default to asking the user.
let policy: ApprovalPolicy = '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
// an answer that will never come. The ACP cancel + force-kill backstop that
// actually ends the turn is handled inside manager.runPrompt via the signal
// we pass below.
const onAbort = () => registry.cancelRun(ctx.runId);
if (ctx.signal.aborted) onAbort();
else ctx.signal.addEventListener('abort', onAbort, { once: true });
let finalText = '';
const changedFiles = new Set<string>();
try {
const result = await manager.runPrompt({
runId: ctx.runId,
agent: effectiveAgent,
cwd: effectiveCwd,
prompt,
policy,
signal: ctx.signal,
onEvent: (event) => {
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
if (event.type === 'tool_call_update') for (const f of event.diffs) changedFiles.add(f);
void ctx.publish({
runId: ctx.runId,
type: 'code-run-event',
toolCallId: ctx.toolCallId,
event,
subflow: [],
});
},
ask: (permAsk) => registry.request(ctx.runId, (requestId) => {
void ctx.publish({
runId: ctx.runId,
type: 'code-run-permission-request',
toolCallId: ctx.toolCallId,
requestId,
ask: permAsk,
subflow: [],
});
}),
});
return {
success: result.stopReason === 'end_turn',
stopReason: result.stopReason,
// The agent that actually ran (the chip), so the UI can label the run
// authoritatively rather than trusting the model's `agent` argument.
agent: effectiveAgent,
summary: finalText.trim(),
changedFiles: [...changedFiles],
};
} catch (error) {
// A stop mid-run isn't a failure — report it as a clean cancellation.
if (ctx.signal.aborted) {
return {
success: false,
stopReason: 'cancelled',
agent: effectiveAgent,
summary: finalText.trim(),
changedFiles: [...changedFiles],
};
}
return {
success: false,
message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`,
};
} finally {
ctx.signal.removeEventListener('abort', onAbort);
}
},
},
// ============================================================================
// Browser Skills (browser-use/browser-harness domain-skills cache)
// ============================================================================
@ -1442,15 +1532,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
inputSchema: CreateBackgroundTaskInput,
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
try {
let projectId: string | undefined;
let warning: string | undefined;
if (input.projectDir) {
const r = await resolveCodeProject(input.projectDir);
if (!r.ok) return { success: false, error: r.error };
projectId = r.projectId;
warning = r.warning;
}
const { createTask } = await import("../../background-tasks/fileops.js");
const result = await createTask({
name: input.name,
instructions: input.instructions,
...(input.triggers ? { triggers: input.triggers } : {}),
...(projectId ? { projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
});
return { success: true, slug: result.slug };
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1463,9 +1562,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try {
const { patchTask } = await import("../../background-tasks/fileops.js");
const { slug, ...partial } = input;
const { slug, projectDir, ...partial } = input;
let warning: string | undefined;
if (projectDir) {
const r = await resolveCodeProject(projectDir);
if (!r.ok) return { success: false, error: r.error };
(partial as { projectId?: string }).projectId = r.projectId;
warning = r.warning;
}
const result = await patchTask(slug, partial);
return { success: true, task: result };
return { success: true, task: result, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1501,6 +1607,35 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'launch-code-task': {
description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.",
inputSchema: z.object({
taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."),
meeting: z.string().min(1).describe("The name/title of the meeting these items came from (e.g. 'Eng Sync — 2026-06-18'). Sessions are grouped under this heading in index.md so the user can see which meeting each change came from."),
title: z.string().min(1).max(120).describe("Short human title for this unit of work — one line in index.md (e.g. 'Add retry to upload client')."),
items: z.string().min(1).describe("Brief description of the action item(s) this session implements, for the summary row (e.g. 'Fix flaky upload + add retry; raised in standup')."),
prompt: z.string().min(1).describe("The full, self-contained coding instruction. Include the concrete goal, relevant context from the meeting, any files/areas to look at, and what 'done' means. The agent runs autonomously with no human — be specific and complete."),
context: z.string().optional().describe("Optional extra context, e.g. the relevant excerpt from the meeting."),
}),
execute: async (input: { taskSlug: string; meeting: string; title: string; items: string; prompt: string; context?: string }, ctx?: ToolContext) => {
try {
const { launchCodeTask } = await import("../../background-tasks/code-sessions.js");
const result = await launchCodeTask({
taskSlug: input.taskSlug,
meeting: input.meeting,
title: input.title,
items: input.items,
prompt: input.prompt,
...(input.context ? { context: input.context } : {}),
...(ctx?.runId ? { runId: ctx.runId } : {}),
});
return result;
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
},
'notify-user': {
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
inputSchema: z.object({

View file

@ -80,7 +80,7 @@ export async function executeCommand(
cwd?: string;
timeout?: number; // timeout in milliseconds
maxBuffer?: number; // max buffer size in bytes
env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx)
env?: NodeJS.ProcessEnv; // override environment
}
): Promise<CommandResult> {
try {

View file

@ -14,6 +14,15 @@ export interface ToolContext {
signal: AbortSignal;
abortRegistry: IAbortRegistry;
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
// The composer code-mode chip for the message that triggered this turn. When set,
// 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

@ -0,0 +1,29 @@
import type { NotificationCategory } from '@x/shared/dist/notification-settings.js';
import { isNotificationCategoryEnabled } from '../../config/notification_config.js';
import type { INotificationService, NotifyInput } from './service.js';
/**
* Fire a notification for `category`, but only if the user has that category
* enabled and the platform supports notifications.
*
* Resolution of the notification service is done via a *dynamic* import of the
* DI container so that callers like the agent runtime which the container
* itself imports don't create a circular module dependency. The whole thing
* is wrapped so a missing service (very early startup), an unsupported
* platform, or a config read error can never disrupt the run/sync that
* triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`).
*/
export async function notifyIfEnabled(
category: NotificationCategory,
input: NotifyInput,
): Promise<void> {
try {
if (!isNotificationCategoryEnabled(category)) return;
const { default: container } = await import('../../di/container.js');
const service = container.resolve<INotificationService>('notificationService');
if (!service.isSupported()) return;
service.notify(input);
} catch (err) {
console.error(`[notifier] failed to notify (category=${category}):`, err);
}
}

View file

@ -4,6 +4,14 @@ export interface NotifyInput {
link?: string;
actionLabel?: string;
secondaryActions?: Array<{ label: string; link: string }>;
/**
* When true, the notification is suppressed if the app is currently in the
* foreground (any window focused). Use for ambient notifications the user
* doesn't need while actively looking at the app (e.g. chat completion, new
* email). Leave unset/false for notifications that must always surface
* regardless of focus (e.g. an agent permission request that blocks a run).
*/
onlyWhenBackground?: boolean;
}
export interface INotificationService {

View file

@ -15,7 +15,8 @@ You are running with **no user present** to clarify, approve, or watch.
Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains:
- \`task.yaml\` — the spec. **Never touch this.** The runtime owns it.
- \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`.
- \`index.md\` — the default agent-owned artifact (a note). You read and write it freely via \`file-readText\` / \`file-editText\`.
- \`index.html\` — optional agent-owned artifact for **visual** output (see OUTPUT MODE). When it exists and is non-empty it is shown to the user instead of \`index.md\`.
- \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does.
You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it.
@ -28,6 +29,12 @@ Use when instructions imply a **current state** artifact:
- "Keep me posted on …" / "What's the latest on …"
On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
Pick the artifact format from what the output needs:
- **\`index.md\`** (default) — prose, lists, summaries, digests, briefs. Rendered as a styled note. Use patch-style edits as above.
- **\`index.html\`** — when the output is inherently **visual**: a dashboard, a metrics table with conditional colors, a chart, a styled report — anything where layout/CSS carry meaning that a plain note would lose. Write a single **self-contained** file with \`file-writeText\` (inline all CSS and JS; avoid external/CDN dependencies as they may be blocked; reference only assets you save next to it in the task folder — relative paths resolve against the folder). It renders full-screen in a sandboxed iframe. HTML is typically regenerated wholesale each run, so a one-shot \`file-writeText\` is fine here.
Use ONE format per task don't maintain both. \`index.html\` wins when present and non-empty. If you move a task from HTML back to a plain note, blank out \`index.html\` (\`file-writeText\` with \`""\`) so \`index.md\` shows again.
ACTION MODE perform a side-effect, append a journal entry.
Use when instructions imply a **recurring action**:
- "Send / draft / post / notify / file / reply / publish / call / forward …"
@ -40,6 +47,12 @@ On every run: perform the action using the appropriate tool (Slack, email, web-f
If your instructions imply BOTH ("summarize and email it"), do both per run.
CODE MODE implement code via isolated sessions.
Only available when the run message contains a **"# Coding task"** block (the task is pinned to a code repository). In that case:
- Detect actionable coding items from the source (e.g. the meeting notes named in the trigger), conservatively. Only implement clearly-scoped, self-contained items. Ambiguous, large/architectural, or other-repo items list them in \`index.md\` as "needs review"; do not code them.
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
- If nothing is actionable, launch nothing and say so in your summary.
# Triggers
The run message tells you which trigger fired and how to interpret it:
@ -69,9 +82,21 @@ The workspace lives at \`${WorkDir}\`.
`;
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
// A running bg-task must not manage bg-tasks: re-running itself risks a
// recursive cascade, and patch/create can clobber its own task.yaml (a weak
// model has done exactly this, dropping the pinned projectId). It implements
// code via `launch-code-task`, not by editing task specs.
const EXCLUDED = new Set([
'executeCommand', // headless: no interactive approval
'code_agent_run', // headless: needs interactive permission UI
'run-background-task-agent',
'create-background-task',
'patch-background-task',
]);
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
if (EXCLUDED.has(name)) continue;
tools[name] = { type: 'builtin', name };
}

View file

@ -0,0 +1,333 @@
import fs from 'fs/promises';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import type { GitStatusFile } from '@x/shared/dist/code-sessions.js';
import container from '../di/container.js';
import type { CodeSessionService } from '../code-mode/sessions/service.js';
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
import * as gitService from '../code-mode/git/service.js';
import { extractAgentResponse } from '../agents/utils.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { fetchTask, taskIndexPath } from './fileops.js';
const log = new PrefixLogger('BgTask:Code');
// A code session that hangs (engine wedged, never settles) shouldn't pin a
// "running…" row forever. After this long we finalize from whatever the
// worktree shows and tell the user to check the session.
const MAX_WATCH_MS = 90 * 60 * 1000;
// A single bg-task run must not spawn an unbounded fleet of code sessions — a
// weak model has called this 11+ times in one run. Cap per agent run.
const MAX_LAUNCHES_PER_RUN = 5;
const launchesPerRun = new Map<string, number>();
export interface LaunchCodeTaskArgs {
/** The bg-task slug — used to find the pinned projectId and to write index.md. */
taskSlug: string;
/** The meeting these items came from — sessions are grouped under it in index.md. */
meeting: string;
/** Short human title for this unit of work (one row in index.md). */
title: string;
/** Short description of the item(s) being implemented (for the row). */
items: string;
/** The detailed, task-specific coding instruction written by the agent. */
prompt: string;
/** Optional extra context (e.g. the relevant meeting excerpt). */
context?: string;
/** The bg-task agent's runId — used to cap launches per run. */
runId?: string;
}
export interface LaunchCodeTaskResult {
success: boolean;
sessionId?: string;
branch?: string;
worktreePath?: string;
error?: string;
}
// Wrap the agent-authored task body in a robust autonomous-coding scaffold so
// every launch gets a strong, self-contained first message regardless of how
// the agent phrased its part. The session runs full-auto (yolo) with no human.
function buildCodePrompt(args: { prompt: string; branch: string; context?: string }): string {
const { prompt, branch, context } = args;
return `You are an autonomous coding agent. There is NO human present to answer questions, approve steps, or review mid-way — make reasonable decisions and drive the task to a complete, working result on your own.
${context ? `## Context\n${context}\n\n` : ''}## Task
${prompt}
## Operating rules
- You are on an isolated branch/worktree (\`${branch}\`). Work only within this repository; your changes never touch the user's main checkout.
- Implement the task end-to-end. Do not stop half-way, leave TODOs/stubs, or defer work back to the user.
- Before you start, briefly explore the repo to match its existing conventions, structure, and style.
- After implementing, VERIFY: run the project's build / typecheck / lint and any directly relevant tests. Fix anything you break.
- Make small, logically-scoped git commits with clear messages as you go.
- Stay in scope don't refactor unrelated code or make sweeping changes the task didn't ask for.
- If the task is genuinely ambiguous or blocked (missing dependency, contradictory requirement), make the safest reasonable partial progress and clearly flag what's blocked in your final summary never guess in a way that could be destructive.
## When done
Finish your response with a section titled exactly \`## Summary\` as the LAST thing you write — nothing after it. Under it, put 25 short bullet points only: what you changed, which files/areas, how you verified it, and any follow-ups or blockers. No narration or preamble inside the summary (no "I then…", "Let me…") — just the facts. This section is shown to the user verbatim, so keep it clean and self-contained.`;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// The code agent's final message is mostly streamed narration ("Let me view it
// in context…"). We instruct it to end with a `## Summary` section — extract just
// that. Fall back to the last paragraph if it didn't comply.
const SUMMARY_MAX_CHARS = 900;
function cleanSummary(text: string): string {
if (!text) return '';
let body: string;
const idx = text.toLowerCase().lastIndexOf('## summary');
if (idx >= 0) {
body = text.slice(idx + '## summary'.length).trim();
} else {
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
body = paras.length ? paras[paras.length - 1] : text.trim();
}
// Drop empty lines and any leftover heading markers; keep bullet structure.
const lines = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trimEnd()).filter((l) => l.trim() !== '');
let out = lines.join('\n').trim();
if (out.length > SUMMARY_MAX_CHARS) out = out.slice(0, SUMMARY_MAX_CHARS).trimEnd() + '…';
return out;
}
// Render a summary as a clean markdown blockquote, preserving its bullet lines.
function quoteSummary(summary: string): string[] {
const cleaned = cleanSummary(summary);
if (!cleaned) return [];
return ['', ...cleaned.split('\n').map((l) => (l.trim() ? `> ${l.trim()}` : '>'))];
}
const SECTION_HEADING = '## Code Sessions';
function startMarker(id: string): string { return `<!-- cs-start:${id} -->`; }
function endMarker(id: string): string { return `<!-- cs-end:${id} -->`; }
function meetingHeading(meeting: string): string {
return `### 📅 ${meeting}`;
}
function runningBlock(args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string }): string {
const { sessionId, title, items, branch, worktreePath } = args;
return [
startMarker(sessionId),
`#### ⏳ ${title}`,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Worktree:** \`${worktreePath}\``,
`- **Session:** \`${sessionId}\` _(running…)_`,
endMarker(sessionId),
].join('\n');
}
// Append a "running" block for a freshly launched session, grouped under its
// meeting's heading inside the Code Sessions section (creating section/heading as
// needed). Serialized via the index.md file lock so concurrent launches don't
// clobber each other.
async function appendRunningBlock(slug: string, meeting: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
content = '';
}
if (!content.includes(SECTION_HEADING)) {
const sep = content.endsWith('\n') || content === '' ? '' : '\n';
content += `${sep}\n${SECTION_HEADING}\n`;
}
const heading = meetingHeading(meeting);
const lines = content.split('\n');
const headingIdx = lines.findIndex((l) => l.trim() === heading);
if (headingIdx === -1) {
// New meeting group — append heading + block at the end.
if (!content.endsWith('\n')) content += '\n';
content += `\n${heading}\n\n${block}\n`;
} else {
// Existing meeting — insert this block right after the heading so
// sessions stay grouped (newest first within the group).
lines.splice(headingIdx + 1, 0, '', block);
content = lines.join('\n');
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Replace a session's block in place once its run settles.
async function finalizeBlock(slug: string, sessionId: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
return; // nothing to finalize against
}
const re = new RegExp(`${escapeRegExp(startMarker(sessionId))}[\\s\\S]*?${escapeRegExp(endMarker(sessionId))}`);
if (re.test(content)) {
content = content.replace(re, block);
} else {
// The running block went missing (manual edit?) — append the final one.
if (!content.endsWith('\n')) content += '\n';
content += `\n${block}\n`;
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Once the code turn settles, summarize from the worktree diff + the agent's
// final message and rewrite the row.
async function finalizeFromResult(
slug: string,
args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string; baseBranch?: string; timedOut?: boolean; error?: string },
): Promise<void> {
const { sessionId, title, items, branch, worktreePath, baseBranch, timedOut, error } = args;
let summary = '';
try {
summary = (await extractAgentResponse(sessionId)) ?? '';
} catch { /* best effort */ }
// Count everything the session changed since it forked — including commits
// (the autonomous scaffold tells the agent to commit, so working-tree status
// alone would read as "no changes"). Fall back to working-tree status if we
// don't know the base.
let files: GitStatusFile[] = [];
try {
files = baseBranch
? await gitService.changedSinceBase(worktreePath, baseBranch)
: await gitService.status(worktreePath);
} catch { /* worktree may be gone */ }
const ins = files.reduce((a, f) => a + (f.insertions ?? 0), 0);
const del = files.reduce((a, f) => a + (f.deletions ?? 0), 0);
let heading: string;
let status: string;
if (error) {
heading = `#### ❌ ${title}`;
status = `Failed — ${error}`;
} else if (timedOut) {
heading = `#### ⌛ ${title}`;
status = `Timed out — open the session to check progress`;
} else if (files.length > 0) {
heading = `#### ✅ ${title}`;
status = `Implemented — ${files.length} file(s) changed (+${ins} / -${del})`;
} else {
heading = `#### ⚠️ ${title}`;
status = `No file changes — open the session for details`;
}
const fileLines = files.slice(0, 25).map((f) => ` - \`${f.path}\` (${f.state})`);
const more = files.length > 25 ? [` - …and ${files.length - 25} more`] : [];
const block = [
startMarker(sessionId),
heading,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Session:** \`${sessionId}\``,
`- **Status:** ${status}`,
...(files.length > 0 ? ['- **Files:**', ...fileLines, ...more] : []),
...quoteSummary(summary),
endMarker(sessionId),
].join('\n');
await finalizeBlock(slug, sessionId, block);
}
/**
* Launch a coding session for a bg-task, asynchronously.
*
* Creates an isolated worktree session (yolo, direct, claude), fires the prompt
* without waiting, writes a "running" row into the task's index.md, and detaches
* a watcher that finalizes the row once the turn settles. Returns as soon as the
* session exists so the bg-task agent can launch more groups (or finish).
*/
export async function launchCodeTask(args: LaunchCodeTaskArgs): Promise<LaunchCodeTaskResult> {
const { taskSlug, meeting, title, items, prompt, context, runId } = args;
// Per-run launch cap — stop a runaway agent from spawning a session fleet.
if (runId) {
const used = launchesPerRun.get(runId) ?? 0;
if (used >= MAX_LAUNCHES_PER_RUN) {
return { success: false, error: `Launch cap reached (${MAX_LAUNCHES_PER_RUN} code sessions per run). Group remaining items instead of launching more.` };
}
launchesPerRun.set(runId, used + 1);
}
const task = await fetchTask(taskSlug);
if (!task) {
return { success: false, error: `Background task '${taskSlug}' not found.` };
}
if (!task.projectId) {
return { success: false, error: `Task '${taskSlug}' has no configured code project (repo). Set one to use launch-code-task.` };
}
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (!project) {
return { success: false, error: `Configured code project '${task.projectId}' is no longer registered.` };
}
const codeSessionService = container.resolve<CodeSessionService>('codeSessionService');
let session;
try {
session = await codeSessionService.create({
projectId: project.id,
title,
agent: 'claude',
mode: 'direct',
policy: 'yolo',
isolation: 'worktree',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: `Could not create code session: ${msg}` };
}
const branch = session.worktree?.branch ?? 'rowboat/' + session.id;
const baseBranch = session.worktree?.baseBranch ?? undefined;
const worktreePath = session.cwd;
await appendRunningBlock(taskSlug, meeting, runningBlock({
sessionId: session.id, title, items, branch, worktreePath,
}));
const wrapped = buildCodePrompt({ prompt, branch, ...(context ? { context } : {}) });
log.log(`${taskSlug} — launched session ${session.id} on ${branch}`);
// Detached: drive the turn to completion, then finalize the index.md row.
// `sendMessage` resolves when the turn settles (it awaits the engine and
// never rejects on engine errors), so we don't need a separate completion
// subscription — but we still cap it with a timeout so a wedged engine can't
// pin the row at "running" forever.
void (async () => {
let timedOut = false;
try {
await Promise.race([
codeSessionService.sendMessage(session.id, wrapped),
new Promise<void>((resolve) => setTimeout(() => { timedOut = true; resolve(); }, MAX_WATCH_MS)),
]);
} catch (err) {
log.log(`${taskSlug} — session ${session.id} errored: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await finalizeFromResult(taskSlug, {
sessionId: session.id, title, items, branch, worktreePath, timedOut,
...(baseBranch ? { baseBranch } : {}),
});
} catch (err) {
log.log(`${taskSlug} — finalize failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
}
})();
return { success: true, sessionId: session.id, branch, worktreePath };
}

View file

@ -97,6 +97,7 @@ export interface CreateTaskInput {
name: string;
instructions: string;
triggers?: BackgroundTask['triggers'];
projectId?: string;
model?: string;
provider?: string;
}
@ -136,6 +137,7 @@ export async function createTask(input: CreateTaskInput): Promise<{ slug: string
instructions: input.instructions,
active: true,
...(input.triggers ? { triggers: input.triggers } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
createdAt: new Date().toISOString(),
@ -194,6 +196,7 @@ export async function listTasks(opts: ListTasksOptions = {}): Promise<ListTasksR
instructions: task.instructions,
active: task.active,
...(task.triggers ? { triggers: task.triggers } : {}),
...(task.projectId ? { projectId: task.projectId } : {}),
createdAt: task.createdAt,
...(task.lastAttemptAt ? { lastAttemptAt: task.lastAttemptAt } : {}),
...(task.lastRunId ? { lastRunId: task.lastRunId } : {}),

View file

@ -31,11 +31,31 @@ const BG_TASK_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this e
const BG_TASK_MANUAL_PAREN = 'user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool';
function buildCodeBlock(slug: string, project: { id: string; path: string; name: string }): string {
return `
# Coding task
This is a **coding task**. It is pinned to a code repository:
- **Project:** ${project.name}
- **Path:** \`${project.path}\`
Your job this run:
1. Read the relevant source (e.g. the meeting notes named in the trigger below) and identify **actionable coding items** bugs to fix, features to build, concrete changes requested.
2. Be **conservative**: only implement items that are clearly scoped and self-contained. Items that are ambiguous, large/architectural, or about a different repository do NOT code them. List them briefly in \`index.md\` as "needs review" instead.
3. **Group** related items together; keep unrelated items separate.
4. For each group, call the \`launch-code-task\` tool with \`taskSlug: "${slug}"\`, the \`meeting\` name/title these items came from (so sessions are grouped by meeting), a short \`title\`, the \`items\` summary, and a **detailed, fully self-contained \`prompt\`** describing exactly what to implement (the coding agent has no other context and no human to ask). Put the relevant meeting excerpt in \`context\`.
5. \`launch-code-task\` runs asynchronously in an isolated git worktree (full-auto) and manages a \`## Code Sessions\` section in \`index.md\` itself — **do not edit that section.** You may add a short note ABOVE it summarizing what you detected.
If there are no actionable coding items, launch nothing and say so in your final summary.`;
}
function buildMessage(
slug: string,
task: BackgroundTask,
trigger: BackgroundTaskTriggerType,
context?: string,
codeProject?: { id: string; path: string; name: string },
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
@ -50,7 +70,7 @@ function buildMessage(
**Instructions:**
${task.instructions}
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).${codeProject ? buildCodeBlock(slug, codeProject) : ''}`;
return baseMessage + buildTriggerBlock({
trigger,
@ -103,6 +123,20 @@ export async function runBackgroundTask(
// `||` not `??`: an empty-string `task.model` (occasionally synthesized
// by an LLM call to create-background-task) should fall through to the
// default just like undefined does.
// Coding tasks carry a pinned code project — resolve it so the run
// message can tell the agent which repo to work in.
let codeProject: { id: string; path: string; name: string } | undefined;
if (task.projectId) {
try {
const { default: container } = await import('../di/container.js');
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
} catch (err) {
log.log(`${slug} — could not resolve code project ${task.projectId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const model = task.model || await getBackgroundTaskAgentModel();
const agentRun = await createRun({
agentId: 'background-task-agent',
@ -129,9 +163,16 @@ export async function runBackgroundTask(
// we leave `lastRunAt` / `lastRunSummary` / `lastRunError` untouched —
// the previous successful run stays visible in the UI even while this
// new run is in-flight or fails.
// `projectId` is runtime-owned config the agent must never lose. A weak
// model can clobber task.yaml mid-run (despite "never touch this"), which
// would silently disable coding on later runs — so we re-assert it on
// every patch to self-heal.
const heal = task.projectId ? { projectId: task.projectId } : {};
await patchTask(slug, {
lastAttemptAt: startedAt,
lastRunId: runId,
...heal,
});
backgroundTaskBus.publish({
@ -142,7 +183,7 @@ export async function runBackgroundTask(
});
try {
await createMessage(runId, buildMessage(slug, task, trigger, context));
await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject));
await waitForRunCompletion(runId, { throwOnError: true });
const summary = await extractAgentResponse(runId);
@ -151,6 +192,7 @@ export async function runBackgroundTask(
lastRunAt: new Date().toISOString(),
lastRunSummary: summary ?? undefined,
lastRunError: undefined,
...heal,
});
log.log(`${slug} — done summary="${truncate(summary)}"`);
@ -171,7 +213,7 @@ export async function runBackgroundTask(
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
// retry-storming.
try {
await patchTask(slug, { lastRunError: msg });
await patchTask(slug, { lastRunError: msg, ...heal });
} catch {
// don't mask the original error
}

View file

@ -1,8 +1,10 @@
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
import type { BillingInfo, BillingPlan } from '@x/shared/dist/billing.js';
import type { BillingInfo, BillingPlanId } from '@x/shared/dist/billing.js';
import { getRowboatConfig } from '../config/rowboat.js';
export async function getBillingInfo(): Promise<BillingInfo> {
const config = await getRowboatConfig();
const accessToken = await getAccessToken();
const response = await fetch(`${API_URL}/v1/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
@ -16,7 +18,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
email: string;
};
billing: {
plan: BillingPlan | null;
planId: BillingPlanId | null;
status: string | null;
trialExpiresAt: string | null;
usage: {
@ -37,9 +39,10 @@ export async function getBillingInfo(): Promise<BillingInfo> {
return {
userEmail: body.user.email ?? null,
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionPlanId: body.billing.planId,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
catalog: config.billing,
monthly: body.billing.usage.monthly,
daily: body.billing.usage.daily,
};

View file

@ -0,0 +1,102 @@
import { createRequire } from 'module';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { CodingAgent } from './types.js';
import { getProvisionedEnginePath } from './engine-provisioner.js';
import { loginShellPath } from './shell-env.js';
const require = createRequire(import.meta.url);
// The ACP adapter npm package that exposes each coding agent as an ACP server.
const ADAPTER_PACKAGE: Record<CodingAgent, string> = {
claude: '@agentclientprotocol/claude-agent-acp',
codex: '@agentclientprotocol/codex-acp',
};
export interface AgentLaunchSpec {
/** Executable to spawn — always `node` so we never hit the Windows .cmd EINVAL. */
command: string;
/** Args = [adapter entry script]. */
args: string[];
/** Extra env merged over process.env (e.g. CLAUDE_CODE_EXECUTABLE on Windows). */
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>`.
function resolveAdapterEntry(pkg: string): string {
const pkgJsonPath = resolveAdapterPkgJson(pkg);
const pkgDir = path.dirname(pkgJsonPath);
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) {
throw new Error(`ACP adapter ${pkg} has no bin entry to spawn`);
}
return path.join(pkgDir, rel);
}
export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec {
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
const env: NodeJS.ProcessEnv = { ...process.env };
// 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
// that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make
// it behave as a plain Node runtime. (Harmless under a real node process, which
// ignores the var.) Without this the child never runs as node and the ACP stdio
// stream closes immediately ("ACP connection closed").
env.ELECTRON_RUN_AS_NODE = '1';
return { command: process.execPath, args: [entry], env };
}

View file

@ -0,0 +1,91 @@
import { execSync } from 'child_process';
import * as path from 'path';
import { existsSync, readFileSync } from 'fs';
import { commonInstallPaths } from '../status.js';
// Windows-only: Node refuses to spawn `.cmd` files without `shell: true` (EINVAL),
// and the Claude ACP adapter spawns its executable directly. So we pre-resolve
// claude's real `.exe` from the npm-shim layout. Used by resolveClaudeExecutable below.
export function resolveClaudeExeOnWindows(): string | undefined {
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
// includes them, which would otherwise leave us unable to find claude.exe and
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
const home = process.env.USERPROFILE ?? '';
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const knownDirs = [
appData && path.join(appData, 'npm'),
localAppData && path.join(localAppData, 'npm'),
appData && path.join(appData, 'pnpm'),
localAppData && path.join(localAppData, 'pnpm'),
home && path.join(home, '.volta', 'bin'),
path.join(programFiles, 'nodejs'),
].filter(Boolean) as string[];
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
const seen = new Set<string>();
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
const key = d.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
for (const dir of candidates) {
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
if (existsSync(exeFromLayout)) return exeFromLayout;
// Otherwise parse the claude.cmd shim for the real exe path.
const cmdPath = path.join(dir, 'claude.cmd');
if (!existsSync(cmdPath)) continue;
try {
const content = readFileSync(cmdPath, 'utf-8');
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
if (relMatch) {
const resolved = path.join(dir, relMatch[1]);
if (existsSync(resolved)) return resolved;
}
} catch {
// ignore shim parse failures
}
}
return undefined;
}
// macOS/Linux: find the real `claude` binary. Unlike Windows this isn't a spawn
// requirement (no .cmd problem) — it's a PATH safety net. Electron apps launched
// from the GUI (Dock/Finder) often don't inherit the login shell's PATH, so the
// spawned adapter may fail to find `claude`. We resolve the path here so the adapter
// can be pointed straight at it.
function resolveClaudeBinaryUnix(): string | undefined {
// Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …).
try {
const out = execSync("/bin/sh -lc 'command -v claude'", { timeout: 5000, encoding: 'utf-8' }).trim();
if (out && existsSync(out)) return out;
} catch {
// not found on the login-shell PATH
}
// Fallback: scan well-known install locations directly.
for (const candidate of commonInstallPaths('claude')) {
if (existsSync(candidate)) return candidate;
}
return undefined;
}
let cached: string | undefined;
// Cross-platform: the real `claude` executable to hand the ACP adapter via
// CLAUDE_CODE_EXECUTABLE (the adapter prefers this env var on every OS). Returns
// undefined if it can't be found — callers then fall back to the adapter's own lookup.
// Cached on first success so we don't re-probe the shell on every cold start.
export function resolveClaudeExecutable(): string | undefined {
if (cached) return cached;
const resolved = process.platform === 'win32' ? resolveClaudeExeOnWindows() : resolveClaudeBinaryUnix();
if (resolved) cached = resolved;
return resolved;
}

View file

@ -0,0 +1,346 @@
import { spawn, type ChildProcess } from 'child_process';
import { Writable, Readable } from 'node:stream';
import fs from 'fs/promises';
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Client,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type SessionUpdate,
type PromptResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import type { CodingAgent, CodeRunEvent } from './types.js';
import type { PermissionBroker } from './permission-broker.js';
import { getAgentLaunchSpec } from './agents.js';
export interface AcpClientOptions {
agent: CodingAgent;
cwd: string;
broker: PermissionBroker;
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;
export interface CodeAgentOption { value: string; label: string }
export interface CodeAgentModelOptions { models: CodeAgentOption[]; efforts: CodeAgentOption[] }
// The agent advertises its model + effort choices on the session it opens (the
// same data that backs its `/model` picker), in one of two shapes:
// - `configOptions`: select options with id "model" / "effort" (Claude).
// - `models`: a SessionModelState { availableModels: [{ modelId, name }] }
// (Codex — which folds effort into the model id, so no separate effort).
// We read configOptions first and fall back to `models`, then prepend a
// synthetic "Default" so the user can always keep the engine default.
type RawSelectOption = { value?: unknown; name?: unknown; options?: Array<{ value?: unknown; name?: unknown }> };
type RawConfigOption = { id?: string; options?: RawSelectOption[] };
type RawModelState = { availableModels?: Array<{ modelId?: unknown; name?: unknown }> };
function withDefault(choices: CodeAgentOption[]): CodeAgentOption[] {
return choices.some((c) => c.value === 'default')
? choices
: [{ value: 'default', label: 'Default' }, ...choices];
}
function toChoices(option: RawConfigOption | undefined): CodeAgentOption[] {
const flat = (option?.options ?? []).flatMap((o) => (Array.isArray(o.options) ? o.options : [o]));
return flat
.filter((o): o is { value: string; name?: unknown } => typeof o.value === 'string')
.map((o) => ({ value: o.value, label: typeof o.name === 'string' && o.name ? o.name : o.value }));
}
function modelStateChoices(models: RawModelState | undefined): CodeAgentOption[] {
return (models?.availableModels ?? [])
.filter((m): m is { modelId: string; name?: unknown } => typeof m.modelId === 'string')
.map((m) => ({ value: m.modelId, label: typeof m.name === 'string' && m.name ? m.name : m.modelId }));
}
export function extractModelOptions(configOptions: unknown, models?: unknown): CodeAgentModelOptions {
const list = (Array.isArray(configOptions) ? configOptions : []) as RawConfigOption[];
const modelOpt = list.find((o) => o.id === 'model');
const effortOpt = list.find((o) => o.id === 'effort');
const modelChoices = toChoices(modelOpt);
return {
// configOptions is authoritative when present; otherwise fall back to the
// SessionModelState list (Codex reports models only there).
models: withDefault(modelChoices.length ? modelChoices : modelStateChoices(models as RawModelState)),
efforts: effortOpt ? withDefault(toChoices(effortOpt)) : [],
};
}
// Claude's `availableModels` exposes its top model only as "Default
// (recommended)" and omits an explicit "Opus" row (the interactive `/model`
// lists it, the ACP adapter dedupes it). Surface the canonical aliases
// explicitly for clarity — the adapter resolves "opus"/"sonnet"/"haiku" to the
// concrete model. Deduped against what the engine already returned, so in
// practice this only adds the missing "Opus" entry, placed right after Default.
const CLAUDE_ALIAS_ROWS: CodeAgentOption[] = [
{ value: 'opus', label: 'Opus' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'haiku', label: 'Haiku' },
];
function withClaudeAliases(options: CodeAgentModelOptions): CodeAgentModelOptions {
const have = new Set(options.models.map((m) => m.value));
const extra = CLAUDE_ALIAS_ROWS.filter((r) => !have.has(r.value));
if (extra.length === 0) return options;
const at = options.models.findIndex((m) => m.value === 'default');
const models = [...options.models];
models.splice(at >= 0 ? at + 1 : 0, 0, ...extra);
return { ...options, models };
}
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
function toEvent(update: SessionUpdate): CodeRunEvent {
switch (update.sessionUpdate) {
case 'agent_message_chunk':
case 'user_message_chunk': {
const c = update.content;
const role = update.sessionUpdate === 'user_message_chunk' ? 'user' : 'agent';
return { type: 'message', role, text: c.type === 'text' ? c.text : `[${c.type}]` };
}
case 'agent_thought_chunk':
return { type: 'thought' };
case 'tool_call':
return {
type: 'tool_call',
id: update.toolCallId,
title: update.title,
kind: update.kind ?? undefined,
status: update.status ?? undefined,
};
case 'tool_call_update': {
const diffs = (update.content ?? [])
.filter((c): c is Extract<typeof c, { type: 'diff' }> => c.type === 'diff')
.map((c) => c.path);
return { type: 'tool_call_update', id: update.toolCallId, status: update.status ?? undefined, diffs };
}
case 'plan':
return {
type: 'plan',
entries: (update.entries ?? []).map((e) => ({
content: e.content,
status: e.status ?? undefined,
priority: e.priority ?? undefined,
})),
};
default:
return { type: 'other', sessionUpdate: update.sessionUpdate };
}
}
// Owns one spawned adapter process + ACP connection. Stateless about sessions —
// the manager decides whether to newSession or loadSession.
//
// The connection is long-lived and reused across follow-up prompts, but each prompt
// may stream to a different message's UI, so broker + onEvent are swappable via
// setHandlers() rather than fixed at construction.
export class AcpClient {
readonly agent: CodingAgent;
readonly cwd: string;
private broker: PermissionBroker;
private onEvent: (event: CodeRunEvent) => void;
private child?: ChildProcess;
private connection?: ClientSideConnection;
private loadSession_ = false;
// Diagnostics: the adapter's stderr/exit are captured so a dropped connection
// reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed".
private stderrTail = '';
private exitInfo: string | null = null;
constructor(opts: AcpClientOptions) {
this.agent = opts.agent;
this.cwd = opts.cwd;
this.broker = opts.broker;
this.onEvent = opts.onEvent;
}
get loadSupported(): boolean {
return this.loadSession_;
}
// Re-point the live connection at a new prompt's broker / event sink.
setHandlers(broker: PermissionBroker, onEvent: (event: CodeRunEvent) => void): void {
this.broker = broker;
this.onEvent = onEvent;
}
// Spawn the adapter and negotiate the protocol. Returns once initialized.
async start(): Promise<void> {
const spec = getAgentLaunchSpec(this.agent);
const child = spawn(spec.command, spec.args, {
cwd: this.cwd,
env: spec.env,
// Capture stderr (not inherit) so we can attribute a dropped connection.
stdio: ['pipe', 'pipe', 'pipe'],
});
this.child = child;
child.stderr?.on('data', (d: Buffer) => {
this.stderrTail = (this.stderrTail + d.toString()).slice(-4000);
});
child.on('exit', (code, signal) => {
this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`;
});
child.on('error', (err) => {
this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000);
});
const stream = ndJsonStream(
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout!) as ReadableStream<Uint8Array>,
);
const client = this.buildClient();
this.connection = new ClientSideConnection(() => client, stream);
try {
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.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
return res.sessionId;
} catch (e) {
throw this.enrich(e, 'newSession');
}
}
// Open a throwaway session purely to read the agent's advertised model +
// effort choices, then let the caller dispose this client. Used for the
// model picker before any real session exists.
async describeModelOptions(): Promise<CodeAgentModelOptions> {
try {
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
const r = res as { configOptions?: unknown; models?: unknown };
const options = extractModelOptions(r.configOptions, r.models);
return this.agent === 'claude' ? withClaudeAliases(options) : options;
} catch (e) {
throw this.enrich(e, 'describeModelOptions');
}
}
async loadSession(sessionId: string): Promise<void> {
try {
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
} catch (e) {
throw this.enrich(e, 'loadSession');
}
}
// Point the open session at a specific model. The adapter resolves aliases
// ("opus"/"sonnet"/…) to concrete ids. Throws if the model is unknown; the
// caller applies this best-effort so a bad value never blocks a turn.
async setModel(sessionId: string, modelId: string): Promise<void> {
await this.conn().unstable_setSessionModel({ sessionId, modelId });
}
// Set the reasoning-effort level via the agent's "effort" config option.
// The option only exists for models that support it, so this throws for
// others — again applied best-effort by the caller.
async setEffort(sessionId: string, value: string): Promise<void> {
await this.conn().setSessionConfigOption({ sessionId, configId: 'effort', value });
}
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
try {
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });
} catch (e) {
throw this.enrich(e, 'prompt');
}
}
// Wrap a connection error with the adapter's exit/stderr so failures are
// self-explanatory rather than the SDK's opaque "ACP connection closed".
private enrich(err: unknown, phase: string): Error {
const base = err instanceof Error ? err.message : String(err);
const parts = [
this.exitInfo,
this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '',
].filter(Boolean);
return new Error(parts.length ? `${base}${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`);
}
async cancel(sessionId: string): Promise<void> {
await this.conn().cancel({ sessionId });
}
dispose(): void {
try {
this.child?.kill();
} catch {
// already gone
}
this.child = undefined;
this.connection = undefined;
}
private conn(): ClientSideConnection {
if (!this.connection) throw new Error('AcpClient not started');
return this.connection;
}
// The client side of ACP: the agent calls these on us. These read the CURRENT
// handlers off `self` so follow-up prompts can swap them via setHandlers().
private buildClient(): Client {
const self = this;
return {
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
return self.broker.resolve(params);
},
async sessionUpdate(params: SessionNotification): Promise<void> {
self.onEvent(toEvent(params.update));
},
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const content = await fs.readFile(params.path, 'utf8');
return { content };
},
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
await fs.writeFile(params.path, params.content);
return {};
},
};
}
}

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,306 @@
// 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 {
let tarCmd = 'tar';
let tarArgs = ['-xzf', tarPath, '-C', destDir, '--strip-components=1'];
let spawnOpts: Parameters<typeof spawnSync>[2] = { stdio: 'pipe' };
// Windows: PATH `tar` may resolve to a GNU tar from Git/MSYS2, which misreads the
// absolute archive path "C:\...\engine.tgz" as a remote "host:path" spec and fails with
// "tar (child): Cannot connect to C: resolve failed" (then "gzip: stdin: unexpected end
// of file"). Pin to the bsdtar shipped in System32, which handles drive-letter paths
// natively — this is the tar this code was always meant to use on Windows 10+.
if (process.platform === 'win32') {
const sysTar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
if (fs.existsSync(sysTar)) {
tarCmd = sysTar;
} else {
// No system bsdtar (very old/stripped Windows): fall back to PATH tar, but run
// from the archive's own directory and pass the bare filename so no drive-letter
// colon reaches tar's -f argument — works for both GNU tar and bsdtar.
tarArgs = ['-xzf', path.basename(tarPath), '-C', destDir, '--strip-components=1'];
spawnOpts = { stdio: 'pipe', cwd: path.dirname(tarPath) };
}
}
const r = spawnSync(tarCmd, tarArgs, spawnOpts);
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

@ -0,0 +1,262 @@
import * as os from 'os';
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
import { AcpClient, type CodeAgentModelOptions } from './client.js';
import { PermissionBroker } from './permission-broker.js';
import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js';
export interface RunPromptArgs {
runId: string;
agent: CodingAgent;
cwd: string;
prompt: string;
policy: ApprovalPolicy;
/** Coding-agent model alias/id (e.g. "opus"); applied to the ACP session
* before the prompt. Omitted / "default" leaves the engine default. */
model?: string;
/** Reasoning-effort level (e.g. "high"); applied alongside the model. */
effort?: string;
/** Called when the policy needs the user to decide (the "ask" path). */
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
/** Stream sink for this prompt's run. */
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 {
client: AcpClient;
sessionId: string;
agent: CodingAgent;
cwd: string;
// Prompts currently streaming on this connection. Disposal is deferred while
// this is > 0 so we never tear down a connection mid-turn.
inflight: number;
// Pending grace-window teardown, cleared if the run is reused before it fires.
disposeTimer?: ReturnType<typeof setTimeout>;
}
// How long a connection stays warm after its last turn ends before we tear it down.
// A coding "turn" is one code_agent_run tool call; we keep the adapter briefly so
// back-to-back calls within one copilot turn (edit -> test -> fix) and quick user
// follow-ups reuse the warm connection instead of cold-starting. Set to 0 for strict
// per-turn teardown. Context is never lost either way: the next turn resumes the
// persisted session via session/load.
const DISPOSE_GRACE_MS = 60_000;
// On stop, how long to let the adapter cancel gracefully (ACP session/cancel) before
// we force-kill it. The kill guarantees the turn unwinds even if the adapter ignores
// cancel or is blocked — otherwise a hung prompt would lock the chat indefinitely.
const CANCEL_GRACE_MS = 2_000;
// Drives ACP coding sessions. A connection's lifetime is scoped to the agent turn
// (one code_agent_run): it is torn down a short grace window after the turn ends, so
// idle chats hold no adapter processes. Turns that land within the grace window reuse
// the warm connection; anything colder (grace elapsed, or after an app restart)
// resumes the persisted session via session/load.
export class CodeModeManager {
private readonly runs = new Map<string, ActiveRun>();
// Per-agent model/effort choices, discovered once from the engine and reused
// (the list only changes when the provider ships new models, and the app can
// be restarted to pick those up). Avoids cold-starting an adapter per picker.
private readonly modelOptionsCache = new Map<CodingAgent, CodeAgentModelOptions>();
// Discover a coding agent's available models + effort levels straight from
// the engine (what its `/model` picker would show). Spawns a short-lived
// adapter, opens a throwaway session to read its advertised options, and
// tears it down. Cached per agent for the lifetime of the process.
async listModelOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
const cached = this.modelOptionsCache.get(agent);
if (cached) return cached;
const broker = new PermissionBroker({ policy: 'yolo', ask: async () => 'reject' });
const client = new AcpClient({ agent, cwd: os.homedir(), broker, onEvent: () => {} });
try {
await client.start();
const options = await client.describeModelOptions();
this.modelOptionsCache.set(agent, options);
return options;
} finally {
client.dispose();
}
}
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
const { runId, agent, cwd, prompt, policy, model, effort, ask, onEvent, signal, suppressReplay } = args;
const broker = new PermissionBroker({
policy,
ask,
onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }),
});
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
// Re-apply the session's model + effort each turn (idempotent): a warm
// connection keeps the last selection, but a cold session/load resets it,
// and the user may have changed it from the header since the last turn.
await this.applyModelAndEffort(run, model, effort);
run.inflight++;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
try {
const promptP = run.client.prompt(run.sessionId, prompt);
// We may stop awaiting this prompt below (force-kill on stop rejects it);
// attach a no-op catch so the orphaned rejection isn't flagged.
promptP.catch(() => {});
// Stop handling: on abort, ask the adapter to cancel; if it hasn't unwound
// within the grace, force-kill it and resolve as cancelled. This guarantees
// the turn ends even if the adapter ignores cancel or is wedged — a hung
// prompt would otherwise lock the chat (no run-stopped, composer disabled).
const cancelledP = new Promise<{ stopReason: string }>((resolve) => {
if (!signal) return;
onAbort = () => {
run.client.cancel(run.sessionId).catch(() => {});
graceTimer = setTimeout(() => {
this.dispose(runId);
resolve({ stopReason: 'cancelled' });
}, CANCEL_GRACE_MS);
graceTimer.unref?.();
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
});
const res = await Promise.race([promptP, cancelledP]);
return { stopReason: res.stopReason, sessionId: run.sessionId };
} catch (e) {
// A kill-induced "connection closed" during a stop is an expected cancel.
if (signal?.aborted) return { stopReason: 'cancelled', sessionId: run.sessionId };
throw e;
} finally {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
if (graceTimer) clearTimeout(graceTimer);
run.inflight--;
this.scheduleDispose(runId);
}
}
// Best-effort: a model the engine doesn't know, or an effort level a model
// doesn't support, must not abort the turn — we log and proceed with the
// engine default rather than surfacing a hard error to the user.
private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise<void> {
if (model && model !== 'default') {
try {
await run.client.setModel(run.sessionId, model);
} catch (e) {
console.warn(`[code-mode] could not set model "${model}": ${e instanceof Error ? e.message : String(e)}`);
}
}
if (effort && effort !== 'default') {
try {
await run.client.setEffort(run.sessionId, effort);
} catch (e) {
console.warn(`[code-mode] could not set effort "${effort}": ${e instanceof Error ? e.message : String(e)}`);
}
}
}
dispose(runId: string): void {
const run = this.runs.get(runId);
if (!run) return;
this.cancelDispose(run);
run.client.dispose();
this.runs.delete(runId);
}
// Tear down the connection a grace window after its last turn ends. Skipped while a
// prompt is still streaming, and re-armed when each turn ends so the window measures
// idle-since-last-activity. With grace 0 we dispose immediately (strict per-turn).
private scheduleDispose(runId: string): void {
const run = this.runs.get(runId);
if (!run || run.inflight > 0) return;
this.cancelDispose(run);
if (DISPOSE_GRACE_MS <= 0) {
this.dispose(runId);
return;
}
run.disposeTimer = setTimeout(() => {
const r = this.runs.get(runId);
if (r && r.inflight === 0) this.dispose(runId);
}, DISPOSE_GRACE_MS);
// A pending teardown timer must not keep the process alive at quit.
run.disposeTimer.unref?.();
}
private cancelDispose(run: ActiveRun): void {
if (run.disposeTimer) {
clearTimeout(run.disposeTimer);
run.disposeTimer = undefined;
}
}
disposeAll(): void {
for (const runId of [...this.runs.keys()]) this.dispose(runId);
}
// Reuse the warm connection if it matches; otherwise (cold start, or the user
// switched agent/cwd for this chat) build a fresh one and create-or-resume its session.
private async ensureRun(
runId: string,
agent: CodingAgent,
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) {
this.cancelDispose(existing); // reused before its grace window elapsed
existing.client.setHandlers(broker, onEvent);
return existing;
}
if (existing) this.dispose(runId); // agent/cwd changed — start over
// 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
// and persist its id so a later restart can resume it.
private async openSession(runId: string, agent: CodingAgent, cwd: string, client: AcpClient): Promise<string> {
const stored = await readStoredSession(runId);
if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) {
try {
await client.loadSession(stored.sessionId);
return stored.sessionId;
} catch {
// Stored session is stale/unloadable — fall through to a fresh one.
await clearStoredSession(runId);
}
}
const sessionId = await client.newSession();
await writeStoredSession({ runId, agent, cwd, sessionId });
return sessionId;
}
}

View file

@ -0,0 +1,91 @@
import type {
RequestPermissionRequest,
RequestPermissionResponse,
PermissionOption,
PermissionOptionKind,
} from '@agentclientprotocol/sdk';
import type { ApprovalPolicy, PermissionDecision, PermissionAsk } from './types.js';
// Tool kinds that don't mutate anything — eligible for `auto-approve-reads`.
const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']);
function toAsk(request: RequestPermissionRequest): PermissionAsk {
const tc = request.toolCall;
const kind = tc.kind ?? undefined;
const title = tc.title ?? kind ?? 'Tool call';
return {
toolCallId: tc.toolCallId ?? undefined,
title,
kind,
isRead: kind ? READ_KINDS.has(kind) : false,
};
}
// Map a desired decision to one of the options the agent actually offered.
// Agents may offer only a subset (e.g. allow_once + reject_once, no allow_always),
// so we fall back within the same allow/reject family before giving up.
function pickOption(options: PermissionOption[], decision: PermissionDecision): PermissionOption | undefined {
const order: Record<PermissionDecision, PermissionOptionKind[]> = {
allow_always: ['allow_always', 'allow_once'],
allow_once: ['allow_once', 'allow_always'],
reject: ['reject_once', 'reject_always'],
};
for (const kind of order[decision]) {
const found = options.find((o) => o.kind === kind);
if (found) return found;
}
return undefined;
}
function selected(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: 'selected', optionId } };
}
// A request's identity for "always allow" memory: prefer tool kind, else title.
function memoryKey(ask: PermissionAsk): string {
return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`;
}
export interface PermissionBrokerOptions {
policy: ApprovalPolicy;
// Called only when the policy can't decide on its own (the "ask" path).
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
// Notified of every resolved request so the engine can emit a stream event.
onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean) => void;
}
// Decides how to answer the agent's requestPermission calls. Holds per-session
// "always allow" memory so a one-time approval sticks for the rest of the run.
export class PermissionBroker {
private readonly opts: PermissionBrokerOptions;
private readonly alwaysAllow = new Set<string>();
constructor(opts: PermissionBrokerOptions) {
this.opts = opts;
}
async resolve(request: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const ask = toAsk(request);
const key = memoryKey(ask);
const finish = (decision: PermissionDecision, auto: boolean): RequestPermissionResponse => {
if (decision === 'allow_always') this.alwaysAllow.add(key);
this.opts.onResolved?.(ask, decision, auto);
const opt = pickOption(request.options, decision);
// If the agent offered no matching option we fall back to its first one
// (don't deadlock the turn); decision precedence above keeps this rare.
return selected(opt?.optionId ?? request.options[0]?.optionId ?? '');
};
// 1. Sticky "always allow" from earlier this session.
if (this.alwaysAllow.has(key)) return finish('allow_always', true);
// 2. Policy-level auto decisions.
if (this.opts.policy === 'yolo') return finish('allow_always', true);
if (this.opts.policy === 'auto-approve-reads' && ask.isRead) return finish('allow_once', true);
// 3. Ask the user.
const decision = await this.opts.ask(ask);
return finish(decision, false);
}
}

View file

@ -0,0 +1,43 @@
import type { PermissionDecision } from './types.js';
interface Pending {
runId: string;
resolve: (decision: PermissionDecision) => void;
}
// Holds in-flight mid-run permission asks. The agent (via the broker) calls
// request() which BLOCKS the coding turn until the user answers; the renderer's
// answer arrives over IPC and calls resolve(). This is separate from the LLM
// tool-loop's pre-call permission gate, which can't model a mid-execution wait.
export class CodePermissionRegistry {
private readonly pending = new Map<string, Pending>();
private counter = 0;
// Register a pending ask, hand the generated requestId to `emit` (so the caller
// can publish the UI event), and resolve once the user answers.
request(runId: string, emit: (requestId: string) => void): Promise<PermissionDecision> {
const requestId = `cpr-${runId}-${++this.counter}`;
return new Promise<PermissionDecision>((resolve) => {
this.pending.set(requestId, { runId, resolve });
emit(requestId);
});
}
// Called from the IPC handler when the user answers a card.
resolve(requestId: string, decision: PermissionDecision): void {
const entry = this.pending.get(requestId);
if (!entry) return;
this.pending.delete(requestId);
entry.resolve(decision);
}
// On run stop/cancel: reject anything still waiting so the turn can unwind.
cancelRun(runId: string): void {
for (const [id, entry] of [...this.pending]) {
if (entry.runId === runId) {
this.pending.delete(id);
entry.resolve('reject');
}
}
}
}

View file

@ -0,0 +1,48 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import type { CodingAgent } from './types.js';
// One ACP session is pinned per chat run. We persist its sessionId (plus the agent
// and cwd it belongs to) so reopening the chat after an app restart can resume the
// same agent context via session/load instead of starting over.
export interface StoredSession {
runId: string;
agent: CodingAgent;
cwd: string;
sessionId: string;
}
// Per-run ACP session state lives in its own directory (not WorkDir/config): it's
// runtime state that accumulates one file per chat run, so it's kept separate from
// user/app config to be listed and cleaned up on its own.
const SESSIONS_DIR = path.join(WorkDir, 'code-mode', 'sessions');
function sessionFile(runId: string): string {
return path.join(SESSIONS_DIR, `${runId}.json`);
}
export async function readStoredSession(runId: string): Promise<StoredSession | null> {
try {
const raw = await fs.readFile(sessionFile(runId), 'utf8');
const parsed = JSON.parse(raw) as StoredSession;
if (parsed && parsed.sessionId && parsed.agent && parsed.cwd) return parsed;
return null;
} catch {
return null;
}
}
export async function writeStoredSession(session: StoredSession): Promise<void> {
const file = sessionFile(session.runId);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify(session, null, 2));
}
export async function clearStoredSession(runId: string): Promise<void> {
try {
await fs.rm(sessionFile(runId), { force: true });
} catch {
// best effort
}
}

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,11 @@
// Rowboat-facing types for the ACP code-mode engine. The schemas live in
// @x/shared (so the IPC/renderer layers share them); we re-export the inferred
// types here so the engine modules import from one local barrel.
export type {
CodingAgent,
ApprovalPolicy,
PermissionDecision,
PermissionAsk,
CodeRunEvent,
RunPromptResult,
} from '@x/shared/dist/code-mode.js';

View file

@ -0,0 +1,337 @@
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;
}
}
async function mergeBase(cwd: string, baseRef: string): Promise<string> {
try {
return (await git(cwd, ['merge-base', baseRef, 'HEAD'])).trim() || baseRef;
} catch {
return baseRef;
}
}
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;
}
// Everything this worktree's branch changed since it forked from `baseRef` —
// committed AND uncommitted. `status()` only sees the working tree (uncommitted),
// so it misses work an agent committed; this is what you want for a session
// summary. Counts come from numstat, states from name-status, merged by path.
export async function changedSinceBase(cwd: string, baseRef: string): Promise<GitStatusFile[]> {
const forkPoint = await mergeBase(cwd, baseRef);
const stateByPath = new Map<string, GitFileState>();
try {
const ns = await git(cwd, ['diff', '--name-status', '-z', forkPoint]);
const parts = ns.split('\0');
for (let i = 0; i < parts.length; i++) {
const code = parts[i];
if (!code) continue;
const letter = code[0];
if (letter === 'R' || letter === 'C') {
// rename/copy: "<code>\0<old>\0<new>"
const newPath = parts[i + 2];
i += 2;
if (newPath) stateByPath.set(newPath, 'renamed');
} else {
const p = parts[i + 1];
i += 1;
if (p) stateByPath.set(p, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified');
}
}
} catch {
// bad ref / no commits — leave states empty
}
const result: GitStatusFile[] = [];
try {
const numstat = await git(cwd, ['diff', '--numstat', '-z', forkPoint]);
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) continue;
result.push({ path: filePath, state: stateByPath.get(filePath) ?? 'modified', insertions, deletions });
}
} catch {
// bad ref / no commits — nothing to report
}
return result;
}
export interface FileDiff {
oldText: string;
newText: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function fileDiff(cwd: string, relPath: string, opts: { baseRef?: string | null } = {}): 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 {
const oldRef = opts.baseRef ? await mergeBase(cwd, opts.baseRef) : 'HEAD';
oldText = await git(cwd, ['show', `${oldRef}:${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,369 @@
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;
// The coding agent's own model + reasoning effort (ACP engine); unset leaves
// the engine default. Re-applied to the ACP session on every turn.
agentModel?: string;
agentEffort?: 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 } : {}),
...(args.agentModel ? { agentModel: args.agentModel } : {}),
...(args.agentEffort ? { agentEffort: args.agentEffort } : {}),
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' | 'agentModel' | 'agentEffort'>>): 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,
...(session.agentModel ? { model: session.agentModel } : {}),
...(session.agentEffort ? { effort: session.agentEffort } : {}),
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);
@ -12,7 +12,7 @@ const execAsync = promisify(exec);
// We scan these directly because Electron's spawned shell sometimes doesn't
// inherit the user's full PATH (especially on macOS GUI launches, and even on
// Windows when global npm prefix isn't propagated to system PATH).
function commonInstallPaths(binary: string): string[] {
export function commonInstallPaths(binary: string): string[] {
const home = os.homedir();
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
@ -40,30 +40,6 @@ 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

@ -1,7 +1,11 @@
import z from "zod";
import { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
export const CodeModeConfig = z.object({
enabled: z.boolean(),
// How the ACP engine answers the coding agent's permission requests.
// Optional for back-compat; the tool defaults to "ask" when unset.
approvalPolicy: ApprovalPolicy.optional(),
});
export type CodeModeConfig = z.infer<typeof CodeModeConfig>;

View file

@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import {
NotificationSettingsSchema,
DEFAULT_NOTIFICATION_SETTINGS,
type NotificationSettings,
type NotificationCategory,
} from '@x/shared/dist/notification-settings.js';
import { WorkDir } from './config.js';
const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json');
/**
* Load notification settings, merging any persisted values over the defaults.
*
* Merging (rather than a strict parse) keeps the file forward/backward
* compatible: a category added in a newer build is filled in from defaults
* when an older file omits it, and a malformed file falls back to defaults
* instead of disabling notifications entirely.
*/
export function loadNotificationSettings(): NotificationSettings {
try {
if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
const categories = parsed?.categories ?? {};
return NotificationSettingsSchema.parse({
categories: {
...DEFAULT_NOTIFICATION_SETTINGS.categories,
...categories,
},
});
}
} catch (error) {
console.error('[NotificationConfig] Error loading notification settings:', error);
}
return DEFAULT_NOTIFICATION_SETTINGS;
}
export function saveNotificationSettings(settings: NotificationSettings): void {
const dir = path.dirname(NOTIFICATION_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const validated = NotificationSettingsSchema.parse(settings);
fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2));
}
/** Convenience: is a single notification category currently enabled? */
export function isNotificationCategoryEnabled(category: NotificationCategory): boolean {
return loadNotificationSettings().categories[category];
}

View file

@ -16,6 +16,12 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
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";
@ -43,6 +49,19 @@ container.register({
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
// ACP code-mode engine: the manager holds a live agent connection per chat only
// around an active turn (torn down after a short idle grace; resumed via
// 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

@ -18,6 +18,9 @@ import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
import { getTagDefinitions } from './tag_system.js';
import { knowledgeSourcesRepo } from './sources/repo.js';
import { syncSlackKnowledgeSources } from './sources/sync_slack.js';
import type { KnowledgeSourceConfig } from './sources/types.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -35,12 +38,11 @@ const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', '
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const SOURCE_FOLDERS = [
'gmail_sync',
path.join('knowledge', 'Meetings', 'fireflies'),
path.join('knowledge', 'Meetings', 'granola'),
path.join('knowledge', 'Meetings', 'rowboat'),
];
function getEnabledFileSources(): KnowledgeSourceConfig[] {
return knowledgeSourcesRepo
.listEnabledSources()
.filter(source => source.provider !== 'voice_memo');
}
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
@ -643,6 +645,15 @@ export async function processAllSources(): Promise<void> {
let anyFilesProcessed = false;
try {
const slackFiles = await syncSlackKnowledgeSources();
if (slackFiles.length > 0) {
console.log(`[GraphBuilder] Slack sync wrote ${slackFiles.length} artifact files`);
}
} catch (error) {
console.error('[GraphBuilder] Error syncing Slack knowledge sources:', error);
}
// Process voice memos first (they get moved to knowledge/)
try {
const voiceMemosProcessed = await processVoiceMemosForKnowledge();
@ -654,12 +665,13 @@ export async function processAllSources(): Promise<void> {
}
const state = loadState();
const folderChanges: { folder: string; sourceDir: string; files: string[] }[] = [];
const folderChanges: { source: KnowledgeSourceConfig; sourceDir: string; files: string[] }[] = [];
const countsByFolder: Record<string, number> = {};
const allFiles: string[] = [];
const fileSources = getEnabledFileSources();
for (const folder of SOURCE_FOLDERS) {
const sourceDir = path.join(WorkDir, folder);
for (const source of fileSources) {
const sourceDir = path.join(WorkDir, source.artifactDir);
// Skip if folder doesn't exist
if (!fs.existsSync(sourceDir)) {
@ -671,7 +683,7 @@ export async function processAllSources(): Promise<void> {
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (folder === 'gmail_sync') {
if (source.provider === 'gmail') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
@ -690,13 +702,13 @@ export async function processAllSources(): Promise<void> {
}
if (filesToProcess.length > 0) {
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);
folderChanges.push({ folder, sourceDir, files: filesToProcess });
countsByFolder[folder] = filesToProcess.length;
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${source.id}`);
folderChanges.push({ source, sourceDir, files: filesToProcess });
countsByFolder[source.id] = filesToProcess.length;
allFiles.push(...filesToProcess);
}
} catch (error) {
console.error(`[GraphBuilder] Error processing ${folder}:`, error);
console.error(`[GraphBuilder] Error processing ${source.id}:`, error);
// Continue with other folders even if one fails
}
}
@ -706,7 +718,7 @@ export async function processAllSources(): Promise<void> {
service: 'graph',
message: 'Syncing knowledge graph',
trigger: 'timer',
config: { sources: SOURCE_FOLDERS },
config: { sources: fileSources.map(source => source.id) },
});
const relativeFiles = allFiles.map(filePath => path.relative(WorkDir, filePath));
@ -770,7 +782,8 @@ export async function processAllSources(): Promise<void> {
*/
export async function init() {
console.log('[GraphBuilder] Starting Knowledge Graph Builder Service...');
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}, knowledge/Voice Memos`);
const sourceFolders = getEnabledFileSources().map(source => source.artifactDir);
console.log(`[GraphBuilder] Monitoring folders: ${sourceFolders.join(', ')}, knowledge/Voice Memos`);
console.log(`[GraphBuilder] Will check for new content every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run

View file

@ -109,7 +109,7 @@ export interface Classification {
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". If a sign-off name is included, use only the user\'s first name. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
@ -139,6 +139,8 @@ Could you resend it with a bit more context so I can get back to you properly?
Best,
John
If you include the user's name in the sign-off, use only their first name, never their full name.
When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:

Some files were not shown because too many files have changed in this diff Show more