mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
Merge branch 'dev' into feat/disk-skills
This commit is contained in:
commit
117a140e03
167 changed files with 19862 additions and 2169 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
271
apps/x/CODE_MODE_ENGINES_PLAN.md
Normal file
271
apps/x/CODE_MODE_ENGINES_PLAN.md
Normal 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.
|
||||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||
// The banner defines __import_meta_url at the top of the bundle,
|
||||
|
|
@ -24,7 +28,11 @@ await esbuild.build({
|
|||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: './.package/dist/main.cjs',
|
||||
external: ['electron'], // Provided by Electron runtime
|
||||
// electron is provided by the runtime. node-pty is a NATIVE module: it can't
|
||||
// be inlined (its loader requires .node binaries + a spawn-helper relative to
|
||||
// its own package dir), so it stays external and is copied into
|
||||
// .package/node_modules below, where require() from dist/main.cjs finds it.
|
||||
external: ['electron', 'node-pty'],
|
||||
// Use CommonJS format - many dependencies use require() which doesn't work
|
||||
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
format: 'cjs',
|
||||
|
|
@ -42,4 +50,73 @@ await esbuild.build({
|
|||
},
|
||||
});
|
||||
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
|
||||
// package dir and copy only what's needed at runtime (compiled JS + prebuilt
|
||||
// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
|
||||
// the bit, and a non-executable helper makes every PTY spawn fail.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
|
||||
const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
|
||||
fs.rmSync(ptyDest, { recursive: true, force: true });
|
||||
fs.mkdirSync(ptyDest, { recursive: true });
|
||||
for (const item of ['package.json', 'lib', 'prebuilds']) {
|
||||
fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
|
||||
}
|
||||
const prebuildsDir = path.join(ptyDest, 'prebuilds');
|
||||
for (const dir of fs.readdirSync(prebuildsDir)) {
|
||||
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
|
||||
// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any
|
||||
// host whose prebuild is absent (notably Linux) the staged package has no loadable
|
||||
// pty.node and the app crashes on launch. Compile the native module for the host
|
||||
// platform+arch if needed and stage it under prebuilds/<platform>-<arch>/, where
|
||||
// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp
|
||||
// step (the CI workflow's explicit build is the fast path; this is the safety net).
|
||||
const hostTriple = `${process.platform}-${process.arch}`;
|
||||
const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node');
|
||||
if (!fs.existsSync(stagedBinary)) {
|
||||
const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node');
|
||||
if (!fs.existsSync(builtBinary)) {
|
||||
console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`);
|
||||
execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' });
|
||||
}
|
||||
if (!fs.existsSync(builtBinary)) {
|
||||
throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(stagedBinary), { recursive: true });
|
||||
fs.copyFileSync(builtBinary, stagedBinary);
|
||||
console.log(`✅ node-pty: staged ${hostTriple}/pty.node`);
|
||||
}
|
||||
console.log('✅ node-pty staged in .package/node_modules');
|
||||
|
||||
// Bundle the vendored agent-slack CLI into a single self-contained script next
|
||||
// to main.cjs. It runs as a child process (process.execPath with
|
||||
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
|
||||
// be inlined into main.cjs. Bundling here means the packaged app needs neither
|
||||
// node_modules nor a global npm install.
|
||||
const agentSlackPkg = JSON.parse(
|
||||
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
await esbuild.build({
|
||||
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
outfile: './.package/dist/agent-slack.cjs',
|
||||
format: 'cjs',
|
||||
banner: { js: cjsBanner },
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
// Without this constant the CLI's --version walks up the directory tree
|
||||
// for a package.json and would find Rowboat's instead of agent-slack's.
|
||||
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
|
||||
},
|
||||
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
|
||||
// back to node:sqlite; keep it external so the probe fails at runtime the
|
||||
// same way it does under plain node.
|
||||
external: ['bun:sqlite'],
|
||||
});
|
||||
|
||||
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,189 @@
|
|||
const path = require('path');
|
||||
const pkg = require('./package.json');
|
||||
|
||||
// The Arch Linux (pacman) package is meant only for local builds on an Arch host
|
||||
// with makepkg. It already self-skips elsewhere (maker-pacman checks for makepkg),
|
||||
// but CI sets ROWBOAT_SKIP_PACMAN=1 to disable it explicitly — GitHub runners are
|
||||
// Ubuntu and shouldn't attempt to ship an Arch package.
|
||||
const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1';
|
||||
|
||||
// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full
|
||||
// production dependency closure into the packaged app.
|
||||
//
|
||||
// Why this is needed: code mode spawns each adapter as a SEPARATE `node <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 the tree instead of copying node_modules: pnpm's store is a
|
||||
// symlink farm that legitimately holds multiple versions of the same package (e.g.
|
||||
// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style
|
||||
// node_modules — dereferencing symlinks — that resolves correctly regardless of pnpm
|
||||
// layout. We HOIST every package to the top-level node_modules and only nest a package
|
||||
// under its requirer on a genuine version conflict. Hoisting (vs. always nesting) keeps
|
||||
// the tree shallow: without it, transitive chains like codex-acp → open → wsl-utils →
|
||||
// is-wsl → is-inside-container → is-docker nest 5+ deep and produce ~260-char paths that
|
||||
// break the Windows Squirrel/nuget maker's MAX_PATH limit. Node resolution stays correct
|
||||
// because the top-level node_modules is an ancestor of every staged file, so a hoisted
|
||||
// package resolves for all requirers and a conflicting version shadows it via nesting.
|
||||
// verifyAcpStaging() below asserts this held for every dependency edge.
|
||||
//
|
||||
// What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped
|
||||
// as platform-specific packages). Those are PROVISIONED on demand into
|
||||
// ~/.rowboat/engines/<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.
|
||||
// Shared by stageAcpAdapters and verifyAcpStaging so staging and verification use
|
||||
// identical resolution semantics.
|
||||
const ACP_ADAPTERS = [
|
||||
'@agentclientprotocol/claude-agent-acp',
|
||||
'@agentclientprotocol/codex-acp',
|
||||
];
|
||||
|
||||
// The native engines, shipped as platform packages. Provisioned on demand
|
||||
// (see header comment), so they're excluded from staging.
|
||||
const isAcpNativeEngine = (key) =>
|
||||
/^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude
|
||||
/^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex
|
||||
|
||||
// Resolve a dependency's real directory by walking node_modules the way Node does,
|
||||
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||
// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
|
||||
// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
|
||||
// Returns null for deps not installed for this OS (platform-optional binaries).
|
||||
const acpRealDirOf = (key, fromDir) => {
|
||||
const fs = require('fs');
|
||||
let dir = fromDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
function stageAcpAdapters(mainDir, destNodeModules) {
|
||||
const fs = require('fs');
|
||||
|
||||
let copied = 0;
|
||||
const skippedEngines = new Set();
|
||||
// srcRealDir -> the staged directory whose content represents it. Lets
|
||||
// verifyAcpStaging map every source package to where it landed.
|
||||
const placements = new Map();
|
||||
// package key -> version placed at the TOP-LEVEL node_modules. We hoist every
|
||||
// package to the top level and only nest a package under its requirer when a
|
||||
// DIFFERENT version is already hoisted there. See the header comment for why
|
||||
// (a shallow tree stays under Windows' MAX_PATH).
|
||||
const rootHoisted = new Map();
|
||||
const install = (srcDir, key, parentNM, chain) => {
|
||||
if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const version = pj.version;
|
||||
const hoisted = rootHoisted.get(key);
|
||||
let destNM;
|
||||
if (hoisted === undefined) {
|
||||
destNM = destNodeModules; // first sighting → hoist to the top level
|
||||
rootHoisted.set(key, version);
|
||||
} else if (hoisted === version) {
|
||||
// identical version already hoisted at root → reuse it (its subtree is
|
||||
// already staged); just record where this srcDir resolves to.
|
||||
placements.set(srcDir, path.join(destNodeModules, ...key.split('/')));
|
||||
return;
|
||||
} else {
|
||||
destNM = parentNM; // genuine version conflict → nest under requirer
|
||||
}
|
||||
const destDir = path.join(destNM, ...key.split('/'));
|
||||
placements.set(srcDir, destDir);
|
||||
if (fs.existsSync(destDir)) return; // already placed at this exact location
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
fs.cpSync(srcDir, destDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion
|
||||
});
|
||||
copied++;
|
||||
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||
const nextChain = new Set(chain).add(srcDir);
|
||||
for (const depKey of Object.keys(deps)) {
|
||||
if (isAcpNativeEngine(depKey)) { skippedEngines.add(depKey); continue; }
|
||||
const depDir = acpRealDirOf(depKey, srcDir);
|
||||
if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of ACP_ADAPTERS) {
|
||||
const srcDir = acpRealDirOf(key, mainDir);
|
||||
if (!srcDir) {
|
||||
throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`);
|
||||
}
|
||||
install(srcDir, key, destNodeModules, new Set());
|
||||
}
|
||||
if (skippedEngines.size) {
|
||||
console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`);
|
||||
}
|
||||
return { copied, placements };
|
||||
}
|
||||
|
||||
// Fail the build LOUDLY if hoisting misplaced anything. Re-walk the source dependency
|
||||
// closure and assert that every (package → dependency) edge resolves, in the STAGED
|
||||
// tree, to the SAME version it resolves to in the SOURCE pnpm tree. This converts a
|
||||
// silent runtime "Cannot find module" (or a wrong-version resolution from a botched
|
||||
// hoist) into an immediate build failure. Expectations are derived from the source
|
||||
// tree — nothing is hardcoded — so it keeps working as the dependency set changes.
|
||||
function verifyAcpStaging(mainDir, placements) {
|
||||
const fs = require('fs');
|
||||
const versionAt = (dir) =>
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
||||
// Resolve `key`'s staged version as seen from `fromStagedDir`, via Node's own
|
||||
// upward node_modules walk. Reads package.json directly (not require.resolve, whose
|
||||
// `${key}/package.json` subpath some exports maps block).
|
||||
const stagedVersionOf = (key, fromStagedDir) => {
|
||||
let dir = fromStagedDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return versionAt(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
const errors = [];
|
||||
const visited = new Set();
|
||||
const walk = (srcDir) => {
|
||||
if (visited.has(srcDir)) return;
|
||||
visited.add(srcDir);
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const stagedDir = placements.get(srcDir);
|
||||
if (!stagedDir) { errors.push(`not staged: ${pj.name}@${pj.version}`); return; }
|
||||
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||
for (const depKey of Object.keys(deps)) {
|
||||
if (isAcpNativeEngine(depKey)) continue;
|
||||
const depSrc = acpRealDirOf(depKey, srcDir);
|
||||
if (!depSrc) continue; // platform-optional / not installed for this OS
|
||||
const want = versionAt(depSrc);
|
||||
const got = stagedVersionOf(depKey, stagedDir);
|
||||
if (got === null) {
|
||||
errors.push(`${pj.name} → ${depKey}: unresolved in staged tree (expected ${want})`);
|
||||
} else if (got !== want) {
|
||||
errors.push(`${pj.name} → ${depKey}: staged resolves ${got}, source resolves ${want}`);
|
||||
}
|
||||
walk(depSrc);
|
||||
}
|
||||
};
|
||||
for (const key of ACP_ADAPTERS) {
|
||||
const srcDir = acpRealDirOf(key, mainDir);
|
||||
if (srcDir) walk(srcDir);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error(
|
||||
`ACP staging verification failed — the staged tree resolves differently than source:\n - ${errors.join('\n - ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
executableName: 'rowboat',
|
||||
|
|
@ -29,17 +212,23 @@ module.exports = {
|
|||
appleIdPassword: process.env.APPLE_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID
|
||||
},
|
||||
// Since we bundle everything with esbuild, we don't need node_modules at all.
|
||||
// These settings prevent Forge's dependency walker (flora-colossus) from trying
|
||||
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
|
||||
// Since we bundle the main process with esbuild, we don't need the workspace
|
||||
// node_modules. These settings prevent Forge's dependency walker (flora-colossus)
|
||||
// from trying to analyze/copy node_modules, which fails with pnpm's symlinked
|
||||
// workspaces.
|
||||
prune: false,
|
||||
ignore: [
|
||||
/src\//,
|
||||
/node_modules\//,
|
||||
/.gitignore/,
|
||||
/bundle\.mjs/,
|
||||
/tsconfig.json/,
|
||||
],
|
||||
// Strip the workspace src/node_modules (paths are ANCHORED to the app root), BUT
|
||||
// always keep everything under `.package/` — that's our staged output: the
|
||||
// bundled main process, the ACP adapters + their dependency closure (staged by
|
||||
// the generateAssets hook), and the native node-pty module (staged into
|
||||
// .package/node_modules by bundle.mjs). Without the `.package` exemption the
|
||||
// node_modules rule would strip those and code mode / the embedded terminal
|
||||
// would break in packaged builds.
|
||||
ignore: (p) => {
|
||||
if (p === '/.package' || p.startsWith('/.package/')) return false;
|
||||
return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
|
||||
.some((re) => re.test(p));
|
||||
},
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
|
|
@ -56,6 +245,7 @@ module.exports = {
|
|||
description: 'AI coworker with memory',
|
||||
name: `Rowboat-win32-${arch}`,
|
||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
@ -66,7 +256,9 @@ module.exports = {
|
|||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -77,10 +269,28 @@ module.exports = {
|
|||
name: `Rowboat-linux`,
|
||||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
}
|
||||
},
|
||||
// Arch Linux package — local-only; disabled in CI via ROWBOAT_SKIP_PACMAN.
|
||||
...(SKIP_PACMAN ? [] : [{
|
||||
name: require.resolve('./makers/maker-pacman.cjs'),
|
||||
platforms: ['linux'],
|
||||
config: {
|
||||
name: 'rowboat',
|
||||
bin: 'rowboat',
|
||||
executableName: 'rowboat',
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
license: 'Apache',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
}]),
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platform: ["darwin", "win32", "linux"],
|
||||
|
|
@ -173,7 +383,18 @@ module.exports = {
|
|||
fs.mkdirSync(rendererDest, { recursive: true });
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
|
||||
// Stage the ACP coding-adapters (+ their JS dependency closure, minus native
|
||||
// engines) into .package/acp/node_modules. They are spawned as separate node
|
||||
// processes at runtime and Forge strips the workspace node_modules, so they
|
||||
// must be copied in explicitly. See stageAcpAdapters() above for the why.
|
||||
console.log('Staging ACP adapters...');
|
||||
const acpDest = path.join(packageDir, 'acp', 'node_modules');
|
||||
const { copied: staged, placements } = stageAcpAdapters(__dirname, acpDest);
|
||||
// Assert the hoisted tree resolves identically to source before shipping it.
|
||||
verifyAcpStaging(__dirname, placements);
|
||||
console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules (resolution verified)`);
|
||||
|
||||
console.log('✅ All assets staged in .package/');
|
||||
},
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
BIN
apps/x/apps/main/icons/icon.ico
Normal file
BIN
apps/x/apps/main/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal 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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null
|
|||
export function dispatchUrl(url: string): void {
|
||||
if (parseAction(url)) {
|
||||
void dispatchAction(url);
|
||||
} else if (parsePickerCompletion(url)) {
|
||||
void dispatchPickerCompletion(url);
|
||||
} else if (parseOAuthCompletion(url)) {
|
||||
void dispatchOAuthCompletion(url);
|
||||
} else {
|
||||
|
|
@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise<void> {
|
|||
await completeRowboatGoogleConnect(parsed.state);
|
||||
}
|
||||
|
||||
// --- Managed OAuth-redirect Picker completion ---
|
||||
|
||||
interface PickerCompletion {
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match rowboat://oauth/google/picker/done?session=<state>. Distinct from the
|
||||
* connect completion above (oauth/google/done) by the extra `picker` segment.
|
||||
*/
|
||||
function parsePickerCompletion(url: string): PickerCompletion | null {
|
||||
if (!url.startsWith(URL_PREFIX)) return null;
|
||||
const rest = url.slice(URL_PREFIX.length);
|
||||
const queryIdx = rest.indexOf("?");
|
||||
const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length !== 4) return null;
|
||||
if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null;
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||
const state = params.get("session");
|
||||
return state ? { state } : null;
|
||||
}
|
||||
|
||||
async function dispatchPickerCompletion(url: string): Promise<void> {
|
||||
const parsed = parsePickerCompletion(url);
|
||||
if (!parsed) return;
|
||||
|
||||
const win = mainWindowRef;
|
||||
if (win && !win.isDestroyed()) focusWindow(win);
|
||||
|
||||
// Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps.
|
||||
const { completeManagedGooglePick } = await import("./google-picker-managed.js");
|
||||
await completeManagedGooglePick(parsed.state);
|
||||
}
|
||||
|
||||
function focusWindow(win: BrowserWindow): void {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
|
|
|
|||
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { shell, BrowserWindow } from 'electron';
|
||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js';
|
||||
import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js';
|
||||
|
||||
// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on
|
||||
// the Rowboat backend with the COMPANY Google client — the desktop never holds
|
||||
// a client_id/secret or an API key. The desktop just opens the start URL, waits
|
||||
// for the deep link, claims the picked file ids, and downloads them with the
|
||||
// user's EXISTING managed Google token (which already holds drive.file from the
|
||||
// main connect). No Picker API key, appId, ngrok, or local OAuth.
|
||||
//
|
||||
// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing
|
||||
// managed Google-connect (start URL → park under session → deep-link back):
|
||||
//
|
||||
// GET ${webappUrl}/oauth/google/picker/start
|
||||
// Runs Google OAuth with the company client, scope=drive.file ONLY,
|
||||
// trigger_onepick=true, prompt=consent. Tied to the logged-in web
|
||||
// session (cookies), exactly like /oauth/google/start.
|
||||
//
|
||||
// GET ${webappUrl}/oauth/google/picker/callback
|
||||
// Google returns `picked_file_ids` (+ code). Park the ids under a
|
||||
// one-shot `session` ticket, then deep-link the desktop:
|
||||
// rowboat://oauth/google/picker/done?session=<state>
|
||||
// (No need to exchange the code: the file is granted to the company
|
||||
// client, so the desktop's existing managed token can read it.)
|
||||
//
|
||||
// POST ${API_URL}/v1/google-oauth/claim-picked body { session }
|
||||
// Authenticated with the user's Rowboat bearer. Returns
|
||||
// { fileIds: string[], tokens: { access_token, ... } } — a fresh
|
||||
// drive.file token minted during the picker's own authorization.
|
||||
|
||||
export interface ManagedPickResult {
|
||||
path: string;
|
||||
doc: GoogleDocListItem;
|
||||
}
|
||||
|
||||
interface PendingPick {
|
||||
targetFolder: string;
|
||||
resolve: (result: ManagedPickResult | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep
|
||||
// link can't carry our targetFolder, so we stash it here for completion.
|
||||
let pending: PendingPick | null = null;
|
||||
const TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function clearPending(): void {
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function focusApp(): void {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the managed picker in the browser and resolve once the deep link comes
|
||||
* back with the user's selection (or null on cancel/timeout). The actual import
|
||||
* happens in completeManagedGooglePick, fired by the deep-link dispatcher.
|
||||
*/
|
||||
export async function startManagedGooglePick(targetFolder: string): Promise<ManagedPickResult | null> {
|
||||
// Supersede any abandoned flow so a stale deep link can't resolve this one.
|
||||
if (pending) {
|
||||
const stale = pending;
|
||||
clearPending();
|
||||
stale.resolve(null);
|
||||
}
|
||||
|
||||
const webappUrl = await getWebappUrl();
|
||||
return await new Promise<ManagedPickResult | null>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (pending) {
|
||||
clearPending();
|
||||
resolve(null);
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
pending = { targetFolder, resolve, reject, timer };
|
||||
void shell.openExternal(`${webappUrl}/oauth/google/picker/start`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link handler for rowboat://oauth/google/picker/done?session=<state>.
|
||||
* Claims the picked file ids from the backend and imports the first one with
|
||||
* the existing managed token, resolving the promise startManagedGooglePick
|
||||
* returned.
|
||||
*/
|
||||
export async function completeManagedGooglePick(session: string): Promise<void> {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)');
|
||||
return;
|
||||
}
|
||||
clearPending();
|
||||
focusApp();
|
||||
|
||||
try {
|
||||
const { fileIds, accessToken } = await claimPickedFilesViaBackend(session);
|
||||
if (fileIds.length === 0 || !accessToken) {
|
||||
current.resolve(null);
|
||||
return;
|
||||
}
|
||||
// Download with the picker's own fresh drive.file token (the main
|
||||
// connection doesn't carry drive.file).
|
||||
const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken);
|
||||
current.resolve(result);
|
||||
} catch (error) {
|
||||
current.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,27 +16,45 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
import { serviceBus } from '@x/core/dist/services/service_bus.js';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import { exec } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import z from 'zod';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection } from '@x/core/dist/models/models.js';
|
||||
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
||||
import { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js';
|
||||
import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
|
||||
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
|
||||
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
|
||||
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
|
||||
import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
|
||||
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
|
||||
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
|
||||
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
|
||||
import type { CodeSession } from '@x/shared/dist/code-sessions.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
|
||||
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
|
||||
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
||||
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import { consumePendingDeepLink } from './deeplink.js';
|
||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||
|
|
@ -51,7 +69,11 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
||||
import { startManagedGooglePick } from './google-picker-managed.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -72,6 +94,190 @@ import {
|
|||
listTasks,
|
||||
readRunIds as readTaskRunIds,
|
||||
} from '@x/core/dist/background-tasks/fileops.js';
|
||||
|
||||
type SlackHomeChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceUrl?: string;
|
||||
workspaceName?: string;
|
||||
};
|
||||
|
||||
type SlackHomeMessage = {
|
||||
id: string;
|
||||
workspaceName?: string;
|
||||
workspaceUrl?: string;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
author?: string;
|
||||
text: string;
|
||||
ts: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
|
||||
const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
|
||||
return (parsed.workspaces || []).map((w) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
}
|
||||
|
||||
type SlackAuthResult = {
|
||||
ok: boolean;
|
||||
workspaces: Array<{ url: string; name: string }>;
|
||||
error?: string;
|
||||
errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
|
||||
};
|
||||
|
||||
// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
|
||||
// Shared by the plain and the quit-Slack-first import handlers.
|
||||
async function importDesktopAndReadWorkspaces(): Promise<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';
|
||||
|
||||
/**
|
||||
|
|
@ -371,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) {
|
||||
|
|
@ -443,6 +675,13 @@ export function setupIpcHandlers() {
|
|||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
||||
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
||||
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
||||
// for full historical coverage of people you've actually emailed.
|
||||
warmContactIndex();
|
||||
warmSentContacts();
|
||||
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
|
|
@ -507,6 +746,9 @@ export function setupIpcHandlers() {
|
|||
'gmail:getAccountEmail': async () => {
|
||||
return { email: await getAccountEmail() };
|
||||
},
|
||||
'gmail:getAccountName': async () => {
|
||||
return { name: await getAccountName() };
|
||||
},
|
||||
'gmail:archiveThread': async (_event, args) => {
|
||||
return archiveThread(args.threadId);
|
||||
},
|
||||
|
|
@ -520,6 +762,22 @@ export function setupIpcHandlers() {
|
|||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
},
|
||||
'gmail:searchContacts': async (_event, args) => {
|
||||
const query = args?.query ?? '';
|
||||
const limit = args?.limit;
|
||||
const excludeEmails = args?.excludeEmails;
|
||||
|
||||
// Primary source: people you've actually sent mail to (Gmail SENT label,
|
||||
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
|
||||
// — used only when the SENT index hasn't been populated yet (very first
|
||||
// launch, before the background sync finishes).
|
||||
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
|
||||
if (sent.length > 0) {
|
||||
return { contacts: sent };
|
||||
}
|
||||
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
|
||||
return { contacts: fallback };
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
@ -530,12 +788,17 @@ export function setupIpcHandlers() {
|
|||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
return { success: true };
|
||||
},
|
||||
'codeRun:resolvePermission': async (_event, args) => {
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
registry.resolve(args.requestId, args.decision);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:provideHumanInput': async (_event, args) => {
|
||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||
return { success: true };
|
||||
|
|
@ -550,6 +813,9 @@ export function setupIpcHandlers() {
|
|||
'runs:list': async (_event, args) => {
|
||||
return runsCore.listRuns(args.cursor);
|
||||
},
|
||||
'runs:listByWorkDir': async (_event, args) => {
|
||||
return runsCore.listRunsByWorkDir(args.dir);
|
||||
},
|
||||
'runs:delete': async (_event, args) => {
|
||||
await runsCore.deleteRun(args.runId);
|
||||
return { success: true };
|
||||
|
|
@ -592,6 +858,24 @@ export function setupIpcHandlers() {
|
|||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
},
|
||||
'models:listForProvider': async (_event, args) => {
|
||||
try {
|
||||
const models = await listModelsForProvider(args.provider);
|
||||
return { success: true, models };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list models';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'llm:getDefaultModel': async () => {
|
||||
return await getDefaultModelAndProvider();
|
||||
},
|
||||
'llm:generate': async (_event, args) => {
|
||||
console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
|
||||
const result = await generateOneShot(args);
|
||||
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
|
||||
return result;
|
||||
},
|
||||
'models:saveConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.setConfig(args);
|
||||
|
|
@ -637,17 +921,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 });
|
||||
|
|
@ -667,21 +1083,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
|
||||
|
|
@ -798,6 +1384,30 @@ export function setupIpcHandlers() {
|
|||
}
|
||||
return { path: result.filePaths[0] ?? null };
|
||||
},
|
||||
'terminal:ensure': async (_event, args) => {
|
||||
return ensureTerminal(args.id, args.cwd, args.cols, args.rows);
|
||||
},
|
||||
'terminal:input': async (_event, args) => {
|
||||
writeTerminal(args.id, args.data);
|
||||
return { success: true };
|
||||
},
|
||||
'terminal:resize': async (_event, args) => {
|
||||
resizeTerminal(args.id, args.cols, args.rows);
|
||||
return { success: true };
|
||||
},
|
||||
'terminal:dispose': async (_event, args) => {
|
||||
disposeTerminal(args.id);
|
||||
return { success: true };
|
||||
},
|
||||
'dialog:openFiles': async (event, args) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
title: args.title ?? 'Attach files',
|
||||
...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}),
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
});
|
||||
return { paths: result.canceled ? [] : result.filePaths };
|
||||
},
|
||||
// Knowledge version history handlers
|
||||
'knowledge:history': async (_event, args) => {
|
||||
const commits = await versionHistory.getFileHistory(args.path);
|
||||
|
|
@ -811,6 +1421,40 @@ export function setupIpcHandlers() {
|
|||
await versionHistory.restoreFile(args.path, args.oid);
|
||||
return { ok: true };
|
||||
},
|
||||
'google-docs:getStatus': async () => {
|
||||
return getGoogleDocsConnectionStatus();
|
||||
},
|
||||
'google-docs:import': async (_event, args) => {
|
||||
console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
|
||||
try {
|
||||
const result = await importGoogleDoc(args.fileId, args.targetFolder);
|
||||
console.log(`[GoogleDocs] import OK -> ${result.path}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[GoogleDocs] import FAILED:', err instanceof Error ? err.message : err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
// Managed (rowboat-mode) OAuth-redirect Picker: the Rowboat backend runs the
|
||||
// pick with the company Google client; the desktop opens the start URL,
|
||||
// waits for the deep link, and imports the picked doc with the existing
|
||||
// managed token. No API key, appId, or local credentials.
|
||||
'google-docs:pickViaManaged': async (_event, args) => {
|
||||
console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`);
|
||||
const result = await startManagedGooglePick(args.targetFolder);
|
||||
if (!result) return null;
|
||||
console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`);
|
||||
return result;
|
||||
},
|
||||
'google-docs:refreshSnapshot': async (_event, args) => {
|
||||
return syncGoogleDocDown(args.path);
|
||||
},
|
||||
'google-docs:sync': async (_event, args) => {
|
||||
return syncGoogleDocUp(args.path, { force: args.force });
|
||||
},
|
||||
'google-docs:getLink': async (_event, args) => {
|
||||
return { link: await getGoogleDocLink(args.path) };
|
||||
},
|
||||
// Search handler
|
||||
'search:query': async (_event, args) => {
|
||||
return search(args.query, args.limit, args.types);
|
||||
|
|
@ -913,6 +1557,24 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
'voice:ensureMicAccess': async () => {
|
||||
if (process.platform !== 'darwin') return { granted: true };
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log('[voice] Microphone permission status:', status);
|
||||
if (status === 'granted') return { granted: true };
|
||||
// 'not-determined' shows the native TCC prompt and resolves once the
|
||||
// user responds; 'denied'/'restricted' resolve false without prompting.
|
||||
// Awaiting this here means the triggering mic click proceeds to
|
||||
// getUserMedia only after permission is settled — fixing the first
|
||||
// click silently failing while the prompt was still up.
|
||||
try {
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone');
|
||||
console.log('[voice] Microphone permission after prompt:', granted);
|
||||
return { granted };
|
||||
} catch {
|
||||
return { granted: false };
|
||||
}
|
||||
},
|
||||
// Live-note handlers
|
||||
'live-note:run': async (_event, args) => {
|
||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||
|
|
@ -1007,6 +1669,7 @@ export function setupIpcHandlers() {
|
|||
name: args.name,
|
||||
instructions: args.instructions,
|
||||
...(args.triggers ? { triggers: args.triggers } : {}),
|
||||
...(args.projectId ? { projectId: args.projectId } : {}),
|
||||
...(args.model ? { model: args.model } : {}),
|
||||
...(args.provider ? { provider: args.provider } : {}),
|
||||
});
|
||||
|
|
@ -1046,6 +1709,13 @@ export function setupIpcHandlers() {
|
|||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
'notifications:getSettings': async () => {
|
||||
return loadNotificationSettings();
|
||||
},
|
||||
'notifications:setSettings': async (_event, args) => {
|
||||
saveNotificationSettings(args);
|
||||
return { success: true };
|
||||
},
|
||||
// Embedded browser handlers (WebContentsView + navigation)
|
||||
...browserIpcHandlers,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from "node:path";
|
|||
import {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startCodeSessionStatusWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
startBackgroundTaskAgentWatcher,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
stopServicesWatcher,
|
||||
stopWorkspaceWatcher
|
||||
} from "./ipc.js";
|
||||
import { disposeAllTerminals } from "./terminal.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
|
|
@ -36,12 +38,13 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
|
|||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -54,7 +57,10 @@ import {
|
|||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
// Captured as early as possible so it reflects actual process start. Used to
|
||||
// gate grace-eligible notifications (e.g. the burst of background-task
|
||||
// completions a reopen replays) — see ElectronNotificationService.
|
||||
const APP_LAUNCHED_AT = Date.now();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -211,6 +217,38 @@ function configureSessionPermissions(targetSession: Session): void {
|
|||
});
|
||||
}
|
||||
|
||||
// Wire Ctrl/Cmd + (+ / − / 0) to zoom the renderer in/out/reset.
|
||||
// The app sets no application menu, so the default menu's zoom roles aren't
|
||||
// available — and on Linux the menu bar is suppressed by the frameless
|
||||
// `hiddenInset` title bar — so handle the accelerators directly here.
|
||||
// `event.preventDefault()` stops the keystroke from leaking into the editor.
|
||||
function setupZoomShortcuts(win: BrowserWindow) {
|
||||
const ZOOM_STEP = 0.5; // zoom-level units (factor = 1.2 ^ level, ~9.5% per step)
|
||||
const MIN_ZOOM_LEVEL = -3;
|
||||
const MAX_ZOOM_LEVEL = 3;
|
||||
const wc = win.webContents;
|
||||
|
||||
wc.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
// Cmd on macOS, Ctrl elsewhere.
|
||||
if (!(process.platform === "darwin" ? input.meta : input.control)) return;
|
||||
|
||||
// input.key is the produced character: "+"/"=" share a physical key (as do
|
||||
// "-"/"_"), and numpad +/- produce the same characters, so this covers both.
|
||||
const key = input.key;
|
||||
if (key === "+" || key === "=") {
|
||||
wc.setZoomLevel(Math.min(wc.getZoomLevel() + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
event.preventDefault();
|
||||
} else if (key === "-" || key === "_") {
|
||||
wc.setZoomLevel(Math.max(wc.getZoomLevel() - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
event.preventDefault();
|
||||
} else if (key === "0") {
|
||||
wc.setZoomLevel(0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
|
|
@ -221,6 +259,7 @@ function createWindow() {
|
|||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
|
|
@ -252,20 +291,43 @@ function createWindow() {
|
|||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank").
|
||||
// Returns true when the URL was external and routed to the system browser.
|
||||
const routeExternalNavigation = (url: string): boolean => {
|
||||
const isInternal =
|
||||
url.startsWith("app://") || url.startsWith("http://localhost:5173");
|
||||
if (!isInternal) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
if (isInternal) return false;
|
||||
shell.openExternal(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
if (routeExternalNavigation(url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Subframe navigations (e.g. links clicked inside the sandboxed iframe that
|
||||
// renders a background-task / workspace `index.html`) fire `will-frame-navigate`,
|
||||
// not `will-navigate`. Route their external links to the system browser too,
|
||||
// so HTML reports behave like the markdown viewer. Main-frame navigations are
|
||||
// already handled by `will-navigate` above — skip them here to avoid double-open.
|
||||
//
|
||||
// Scope this to our own HTML viewer frames (identified by their app://workspace
|
||||
// document origin). Third-party note embeds (YouTube, Figma, Twitter via the
|
||||
// embed/iframe blocks) load from their own origins — leave their internal
|
||||
// navigation untouched so the embeds keep working.
|
||||
win.webContents.on("will-frame-navigate", (event) => {
|
||||
if (event.isMainFrame) return;
|
||||
if (!event.frame?.url.startsWith("app://workspace/")) return;
|
||||
if (routeExternalNavigation(event.url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Attach the embedded browser pane manager to this window.
|
||||
// The WebContentsView is created lazily on first `browser:setVisible`.
|
||||
browserViewManager.attach(win);
|
||||
|
||||
// Cmd/Ctrl + (+ / − / 0) zoom shortcuts for the renderer UI.
|
||||
setupZoomShortcuts(win);
|
||||
|
||||
if (app.isPackaged) {
|
||||
win.loadURL("app://-/index.html");
|
||||
} else {
|
||||
|
|
@ -290,18 +352,13 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Ensure agent-slack CLI is available
|
||||
try {
|
||||
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
|
||||
} catch {
|
||||
try {
|
||||
console.log('agent-slack not found, installing...');
|
||||
await execAsync('npm install -g agent-slack', { timeout: 60000 });
|
||||
console.log('agent-slack installed successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to install agent-slack:', e);
|
||||
}
|
||||
}
|
||||
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
|
||||
// and is resolved per call by the shared executor in @x/core. Availability is
|
||||
// exposed to the UI via the slack:cliStatus IPC channel; this startup log is
|
||||
// diagnostics only.
|
||||
getAgentSlackCliStatus().then((status) => {
|
||||
console.log('[Slack] agent-slack CLI status:', status);
|
||||
}).catch(() => { /* probe failures already surface through slack:cliStatus */ });
|
||||
|
||||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
|
@ -314,7 +371,7 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
|
@ -331,6 +388,9 @@ app.whenReady().then(async () => {
|
|||
// start runs watcher
|
||||
startRunsWatcher();
|
||||
|
||||
// start code-session status tracker (derives working/needs-you/idle + notifications)
|
||||
startCodeSessionStatusWatcher();
|
||||
|
||||
// start services watcher
|
||||
startServicesWatcher();
|
||||
|
||||
|
|
@ -421,7 +481,15 @@ app.on("before-quit", () => {
|
|||
stopWorkspaceWatcher();
|
||||
stopRunsWatcher();
|
||||
stopServicesWatcher();
|
||||
stopSkillsWatcher();
|
||||
stopSkillsWatcher();
|
||||
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
||||
try {
|
||||
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
||||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
// Kill embedded terminal shells.
|
||||
disposeAllTerminals();
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserWindow, Notification, shell } from "electron";
|
||||
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
|
||||
import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js";
|
||||
import { dispatchUrl } from "../deeplink.js";
|
||||
|
||||
const HTTP_URL = /^https?:\/\//i;
|
||||
|
|
@ -11,11 +12,35 @@ export class ElectronNotificationService implements INotificationService {
|
|||
// gets dropped and macOS clicks just focus the app silently.
|
||||
private active = new Set<Notification>();
|
||||
|
||||
// Timestamp the app launched, used to gate grace-eligible notifications (see
|
||||
// NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts
|
||||
// so it reflects process start rather than whenever the first notify fires.
|
||||
private readonly launchedAt: number;
|
||||
|
||||
constructor(launchedAt: number = Date.now()) {
|
||||
this.launchedAt = launchedAt;
|
||||
}
|
||||
|
||||
isSupported(): boolean {
|
||||
return Notification.isSupported();
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void {
|
||||
// Startup grace: a reopen replays every background task that completed
|
||||
// while the app was closed, so grace-eligible notifications fired in the
|
||||
// first moments after launch are dropped to avoid a notification flood.
|
||||
if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ambient notifications are suppressed while the app is in the
|
||||
// foreground — the user is already looking at it. A window counts as
|
||||
// foreground only if it's actually focused (minimized / other-space
|
||||
// windows are not), so this correctly treats those as background.
|
||||
if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the actions array AND a parallel index → link map.
|
||||
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
||||
// additional ones live behind the chevron menu.
|
||||
|
|
|
|||
|
|
@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
signedInUserId = billing.userId;
|
||||
analyticsIdentify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
plan: billing.subscriptionPlanId,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
analyticsCapture('user_signed_in', {
|
||||
plan: billing.subscriptionPlan,
|
||||
plan: billing.subscriptionPlanId,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
}
|
||||
|
|
@ -420,15 +420,23 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
// Google only returns a refresh_token when offline access is requested,
|
||||
// and only re-issues one when re-consent is forced. Without these, a
|
||||
// BYOK token expires after ~1h with no way to refresh (it goes stale and
|
||||
// every Google call — including the Picker — starts failing).
|
||||
...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}),
|
||||
});
|
||||
|
||||
// Set timeout to clean up abandoned flows (2 minutes)
|
||||
// Set timeout to clean up abandoned flows. Generous (10 min) because a
|
||||
// first-time connect can involve creating/locating OAuth credentials in
|
||||
// the Cloud Console mid-flow; a short window tears down the callback
|
||||
// server before the user finishes consent, silently dropping the token.
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlow?.state === state) {
|
||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||
cancelActiveFlow('timed_out');
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
activeFlow = {
|
||||
provider,
|
||||
|
|
|
|||
126
apps/x/apps/main/src/terminal.ts
Normal file
126
apps/x/apps/main/src/terminal.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -82,64 +82,40 @@
|
|||
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Inbox surface — mapped onto the app's design tokens so it follows the same
|
||||
palette, accent, and theme switching as the rest of Rowboat. Light/dark is
|
||||
handled by the app tokens (:root / .dark), so no separate .light override. */
|
||||
.gmail-shell {
|
||||
--gm-bg: #0f0f12;
|
||||
--gm-bg-card: #131317;
|
||||
--gm-bg-input: #1c1c20;
|
||||
--gm-bg-row-hover: #16161a;
|
||||
--gm-bg-row-selected: #1a1620;
|
||||
--gm-bg-row-selected-hover: #1d1825;
|
||||
--gm-bg-iframe: #fafafa;
|
||||
--gm-bg-pill: #1c1c20;
|
||||
--gm-bg-pill-hover: #232328;
|
||||
--gm-text: #e4e4e7;
|
||||
--gm-text-strong: #fafafa;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-faint: #52525b;
|
||||
--gm-text-body: #d4d4d8;
|
||||
--gm-border: #1f1f24;
|
||||
--gm-border-strong: #2e2e35;
|
||||
--gm-accent: #a78bfa;
|
||||
--gm-accent-hover: #b9a6ff;
|
||||
--gm-accent-glow: rgba(167, 139, 250, 0.45);
|
||||
--gm-accent-fg: #18181b;
|
||||
--gm-icon-hover-bg: #1f1f24;
|
||||
--gm-placeholder: #52525b;
|
||||
--gm-bg: var(--background);
|
||||
--gm-bg-card: var(--card-surface);
|
||||
--gm-bg-input: var(--muted);
|
||||
--gm-bg-row-hover: var(--accent);
|
||||
--gm-bg-row-selected: var(--rowboat-wash);
|
||||
--gm-bg-row-selected-hover: color-mix(in oklab, var(--primary) 18%, var(--background));
|
||||
--gm-bg-iframe: #ffffff;
|
||||
--gm-bg-pill: var(--background);
|
||||
--gm-bg-pill-hover: var(--accent);
|
||||
--gm-text: var(--foreground);
|
||||
--gm-text-strong: var(--foreground);
|
||||
--gm-text-muted: var(--muted-foreground);
|
||||
--gm-text-faint: color-mix(in oklab, var(--muted-foreground) 70%, var(--background));
|
||||
--gm-text-body: color-mix(in oklab, var(--foreground) 88%, var(--background));
|
||||
--gm-border: var(--border);
|
||||
--gm-border-strong: var(--rowboat-hairline);
|
||||
--gm-accent: var(--primary);
|
||||
--gm-accent-hover: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
--gm-accent-glow: color-mix(in oklab, var(--primary) 40%, transparent);
|
||||
--gm-accent-fg: var(--primary-foreground);
|
||||
--gm-icon-hover-bg: var(--accent);
|
||||
--gm-placeholder: var(--muted-foreground);
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg);
|
||||
color: var(--gm-text);
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
.light .gmail-shell {
|
||||
--gm-bg: #ffffff;
|
||||
--gm-bg-card: #ffffff;
|
||||
--gm-bg-input: #f4f4f7;
|
||||
--gm-bg-row-hover: #f7f7f9;
|
||||
--gm-bg-row-selected: #f5f0ff;
|
||||
--gm-bg-row-selected-hover: #ece4ff;
|
||||
--gm-bg-iframe: #ffffff;
|
||||
--gm-bg-pill: #ffffff;
|
||||
--gm-bg-pill-hover: #f4f4f7;
|
||||
--gm-text: #27272a;
|
||||
--gm-text-strong: #09090b;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-faint: #a1a1aa;
|
||||
--gm-text-body: #3f3f46;
|
||||
--gm-border: #e4e4e7;
|
||||
--gm-border-strong: #d4d4d8;
|
||||
--gm-accent: #7c3aed;
|
||||
--gm-accent-hover: #6d28d9;
|
||||
--gm-accent-glow: rgba(124, 58, 237, 0.3);
|
||||
--gm-accent-fg: #ffffff;
|
||||
--gm-icon-hover-bg: #f4f4f7;
|
||||
--gm-placeholder: #a1a1aa;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.gmail-main {
|
||||
|
|
@ -160,6 +136,13 @@
|
|||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gmail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -319,7 +302,7 @@
|
|||
}
|
||||
|
||||
.gmail-row-action-danger:hover {
|
||||
color: #e8453c;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.gmail-row:hover {
|
||||
|
|
@ -707,411 +690,78 @@
|
|||
border-color: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-card {
|
||||
max-width: 720px;
|
||||
margin-left: 40px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg-card);
|
||||
}
|
||||
|
||||
.gmail-compose-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text-body);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-compose-header button,
|
||||
.gmail-compose-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-compose-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gmail-compose-line input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.gmail-compose-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-compose-subject-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Recipient (To / Cc / Bcc) rows with editable chips */
|
||||
.gmail-recipient-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
padding-top: 5px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-recipient-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-recipient-input {
|
||||
flex: 1 1 80px;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-trailing {
|
||||
flex: none;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button:hover {
|
||||
color: var(--gm-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--gm-border);
|
||||
background: var(--gm-bg-input);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--gm-bg-card);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover input:focus {
|
||||
border-color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover button:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover-apply {
|
||||
background: var(--gm-accent) !important;
|
||||
border-color: var(--gm-accent) !important;
|
||||
color: var(--gm-accent-fg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover-apply:hover {
|
||||
background: var(--gm-accent-hover) !important;
|
||||
border-color: var(--gm-accent-hover) !important;
|
||||
}
|
||||
|
||||
.gmail-compose-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-compose-tool:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-tool.is-active {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-tool-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 6px;
|
||||
background: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gmail-compose-content {
|
||||
/* Email composer body — Tiptap content, styled to the app's design system. */
|
||||
.compose-content {
|
||||
outline: none;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: 13px/1.55 inherit;
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gmail-compose-content p {
|
||||
.compose-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content p + p,
|
||||
.gmail-compose-content p + ul,
|
||||
.gmail-compose-content p + ol,
|
||||
.gmail-compose-content p + blockquote {
|
||||
.compose-content p + p,
|
||||
.compose-content p + ul,
|
||||
.compose-content p + ol,
|
||||
.compose-content p + blockquote {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gmail-compose-content ul,
|
||||
.gmail-compose-content ol {
|
||||
.compose-content ul,
|
||||
.compose-content ol {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.gmail-compose-content ul {
|
||||
.compose-content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.gmail-compose-content ol {
|
||||
.compose-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.gmail-compose-content li {
|
||||
.compose-content li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content li > p {
|
||||
.compose-content li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content blockquote {
|
||||
.compose-content blockquote {
|
||||
margin: 4px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--gm-border-strong);
|
||||
color: var(--gm-text-muted);
|
||||
border-left: 2px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gmail-compose-content a {
|
||||
color: var(--gm-accent);
|
||||
.compose-content a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-content code {
|
||||
.compose-content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--gm-bg-pill-hover);
|
||||
background: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gmail-compose-content p.is-editor-empty:first-child::before {
|
||||
.compose-content p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--gm-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gmail-compose-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-actions-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-refine-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-refine-button:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
border-color: var(--gm-accent);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-send-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--gm-accent);
|
||||
color: var(--gm-accent-fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-send-button:hover {
|
||||
background: var(--gm-accent-hover);
|
||||
}
|
||||
|
||||
.gmail-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1367,6 +1017,17 @@
|
|||
var(--rowboat-shadow);
|
||||
}
|
||||
|
||||
.dark .rowboat-code-chat-input {
|
||||
border-color: color-mix(in oklab, var(--border) 76%, transparent);
|
||||
background: #000000;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dark .rowboat-code-chat-input:focus-within {
|
||||
border-color: color-mix(in oklab, var(--ring) 70%, var(--border) 30%);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--ring) 30%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="message-content"] {
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ export const Conversation = ({
|
|||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
const updateBottomState = useCallback(() => {
|
||||
|
|
@ -50,7 +51,9 @@ export const Conversation = ({
|
|||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX);
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
stickToBottomRef.current = atBottom;
|
||||
setIsAtBottom(atBottom);
|
||||
}, []);
|
||||
|
||||
const applyAnchorLayout = useCallback(
|
||||
|
|
@ -131,7 +134,12 @@ export const Conversation = ({
|
|||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const shouldStick = !anchorMessageId && stickToBottomRef.current;
|
||||
applyAnchorLayout(false);
|
||||
if (shouldStick) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateBottomState();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -178,6 +186,7 @@ export const Conversation = ({
|
|||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
stickToBottomRef.current = true;
|
||||
updateBottomState();
|
||||
}, [updateBottomState]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react'
|
||||
import { Bot, Mail, Sparkles, Telescope } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
export interface ChatEmptyStateRun {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
}
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
recentRuns?: ChatEmptyStateRun[]
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
/** Fill the composer with a starter prompt (does not submit). */
|
||||
onPickPrompt: (prompt: string) => void
|
||||
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
|
||||
|
|
@ -26,13 +17,10 @@ const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt
|
|||
]
|
||||
|
||||
/**
|
||||
* Empty-state body for the chat surface: greeting, recent chats, and starter
|
||||
* action cards. Shown in both the side-pane copilot and full-screen chat.
|
||||
* Empty-state body for the chat surface: greeting and starter action cards.
|
||||
* Shown in both the side-pane copilot and full-screen chat.
|
||||
*/
|
||||
export function ChatEmptyState({
|
||||
recentRuns = [],
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
onPickPrompt,
|
||||
wide = false,
|
||||
}: ChatEmptyStateProps) {
|
||||
|
|
@ -44,45 +32,13 @@ export function ChatEmptyState({
|
|||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or start with a suggestion.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentRuns.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="flex-1">Recent chats</span>
|
||||
{onOpenChatHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenChatHistory}
|
||||
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowUpRight className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{recentRuns.slice(0, 4).map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onSelectRun?.(run.id)}
|
||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
|
||||
Get started
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{SUGGESTED_ACTIONS.map((action) => (
|
||||
|
|
@ -101,6 +57,8 @@ export function ChatEmptyState({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolConnectionsCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -164,9 +177,10 @@ interface ChatSidebarProps {
|
|||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onSubmitRecording?: () => void | Promise<void>
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
|
|
@ -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,13 @@ export function ChatSidebar({
|
|||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
recentRuns={recentRuns}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={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 +692,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 +804,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}
|
||||
|
|
|
|||
134
apps/x/apps/renderer/src/components/code/cm.ts
Normal file
134
apps/x/apps/renderer/src/components/code/cm.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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[] {
|
||||
const bg = isDark ? '#0f1117' : '#ffffff'
|
||||
const panelBg = isDark ? '#151821' : '#f6f8fa'
|
||||
const text = isDark ? '#d4d4d8' : '#24292f'
|
||||
const muted = isDark ? '#7d8590' : '#6e7781'
|
||||
const border = isDark ? '#2f3542' : '#d0d7de'
|
||||
|
||||
return [
|
||||
lineNumbers(),
|
||||
bracketMatching(),
|
||||
syntaxHighlighting(isDark ? darkHighlight : defaultHighlightStyle, { fallback: true }),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
fontSize: '12px',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-editor': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: text,
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-line': {
|
||||
color: text,
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: panelBg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: muted,
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)',
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: isDark ? 'rgba(88, 166, 255, 0.32)' : 'rgba(9, 105, 218, 0.22)',
|
||||
},
|
||||
'.cm-panels, .cm-panel': {
|
||||
backgroundColor: panelBg,
|
||||
color: text,
|
||||
borderColor: border,
|
||||
},
|
||||
'.cm-mergeView': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
},
|
||||
'.cm-mergeViewEditors': {
|
||||
backgroundColor: bg,
|
||||
},
|
||||
'.cm-mergeView .cm-editor': {
|
||||
borderColor: border,
|
||||
},
|
||||
'.cm-changedLine': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.14)' : 'rgba(9, 105, 218, 0.08)',
|
||||
},
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: isDark ? 'rgba(248, 81, 73, 0.14)' : 'rgba(255, 235, 233, 0.95)',
|
||||
},
|
||||
'.cm-insertedLine, .cm-insertedChunk': {
|
||||
backgroundColor: isDark ? 'rgba(63, 185, 80, 0.14)' : 'rgba(234, 255, 234, 0.95)',
|
||||
},
|
||||
'&.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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
496
apps/x/apps/renderer/src/components/code/code-chat.tsx
Normal file
496
apps/x/apps/renderer/src/components/code/code-chat.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { useEffect, useRef, useState, type DragEvent, type MutableRefObject } from 'react'
|
||||
import { ArrowUp, FileText, Loader2, LoaderIcon, Mic, 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 { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||
import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat'
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
const WAVE_BAR_WIDTH = 3
|
||||
const WAVE_BAR_GAP = 2
|
||||
const WAVE_BAR_MIN = 1.5
|
||||
const WAVE_BAR_MAX = 18
|
||||
|
||||
function VoiceWaveform({ audioLevelsRef }: { audioLevelsRef: MutableRefObject<number[]> }) {
|
||||
const [bars, setBars] = useState<number[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let lastSig = ''
|
||||
const tick = () => {
|
||||
const levels = audioLevelsRef.current
|
||||
const next = levels.length > 48 ? levels.slice(levels.length - 48) : levels
|
||||
const sig = `${next.length}:${next.length ? next[next.length - 1] : 0}`
|
||||
if (sig !== lastSig) {
|
||||
lastSig = sig
|
||||
setBars(next.slice())
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [audioLevelsRef])
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-full items-center overflow-hidden" style={{ gap: WAVE_BAR_GAP }}>
|
||||
{bars.map((level, i) => {
|
||||
const amp = Math.min(1, Math.max(0, level)) ** 0.8
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="shrink-0 rounded-full bg-primary"
|
||||
style={{
|
||||
width: WAVE_BAR_WIDTH,
|
||||
height: WAVE_BAR_MIN + amp * (WAVE_BAR_MAX - WAVE_BAR_MIN),
|
||||
transition: 'height 90ms linear',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
voiceAvailable = false,
|
||||
}: {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
onOpenDiff: (path: string) => void
|
||||
voiceAvailable?: boolean
|
||||
}) {
|
||||
const {
|
||||
items, liveText, isProcessing, compactionStatus, contextUsage,
|
||||
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 voice = useVoiceMode()
|
||||
const voiceWarmup = voice.warmup
|
||||
|
||||
const busy = isProcessing || status === 'working' || status === 'needs-you'
|
||||
const recording = voice.state !== 'idle'
|
||||
const recordingStopping = voice.state === 'submitting'
|
||||
const contextUsedPercent = contextUsage
|
||||
? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100))
|
||||
: null
|
||||
// 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)
|
||||
voice.cancel()
|
||||
textareaRef.current?.focus()
|
||||
}, [session.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy) setStopping(false)
|
||||
}, [busy])
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceAvailable) voiceWarmup()
|
||||
}, [voiceAvailable, voiceWarmup])
|
||||
|
||||
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: 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 handleStartRecording = () => {
|
||||
if (busy) return
|
||||
void voice.start()
|
||||
}
|
||||
|
||||
const handleSubmitRecording = async () => {
|
||||
if (!recording || recordingStopping) return
|
||||
const text = await voice.submit()
|
||||
if (!text) return
|
||||
const result = await send(text)
|
||||
if (!result.ok && result.error) {
|
||||
toast.error(result.error)
|
||||
setDraft(text)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRecording = () => {
|
||||
voice.cancel()
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
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
|
||||
{contextUsedPercent != null ? ` · ${contextUsedPercent}% context used` : ''}
|
||||
</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 && (
|
||||
compactionStatus === 'stalled' ? (
|
||||
<div className="text-sm text-amber-600">
|
||||
Context compaction is taking longer than expected. You can stop and retry in a fresh session.
|
||||
</div>
|
||||
) : (
|
||||
<Shimmer className="text-sm">
|
||||
{stopping
|
||||
? 'Stopping…'
|
||||
: compactionStatus === 'running'
|
||||
? 'Compacting context…'
|
||||
: `${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="bg-background p-3 dark:bg-black">
|
||||
<div className="rowboat-chat-input rowboat-code-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>
|
||||
)}
|
||||
{recording ? (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelRecording}
|
||||
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="Cancel recording"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||
<VoiceWaveform audioLevelsRef={voice.audioLevelsRef} />
|
||||
<div className={cn('min-h-5 truncate text-sm leading-5', voice.interimText.trim() ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{voice.interimText.trim() || (recordingStopping ? 'Finalizing...' : 'Listening...')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => void handleSubmitRecording()}
|
||||
disabled={recordingStopping}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
recordingStopping
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
)}
|
||||
>
|
||||
{recordingStopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
{voiceAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartRecording}
|
||||
disabled={busy || recording}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Voice input"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Voice input</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>
|
||||
)
|
||||
}
|
||||
404
apps/x/apps/renderer/src/components/code/code-view.tsx
Normal file
404
apps/x/apps/renderer/src/components/code/code-view.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
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 className="bg-background pb-3 dark:bg-black" style={{ height: terminalHeight + 12 }}>
|
||||
<div className="h-full min-h-0">
|
||||
<TerminalPane
|
||||
key={selectedSession.id}
|
||||
terminalId={selectedSession.id}
|
||||
cwd={selectedSession.cwd}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
121
apps/x/apps/renderer/src/components/code/diff-viewer.tsx
Normal file
121
apps/x/apps/renderer/src/components/code/diff-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
apps/x/apps/renderer/src/components/code/file-tree.tsx
Normal file
101
apps/x/apps/renderer/src/components/code/file-tree.tsx
Normal 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>
|
||||
}
|
||||
70
apps/x/apps/renderer/src/components/code/file-viewer.tsx
Normal file
70
apps/x/apps/renderer/src/components/code/file-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
372
apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Normal file
372
apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Normal 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')
|
||||
// Direct drive by default; Rowboat orchestration remains an opt-in per session.
|
||||
const [mode, setMode] = useState<CodeSessionMode>('direct')
|
||||
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('direct')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
179
apps/x/apps/renderer/src/components/code/session-rail.tsx
Normal file
179
apps/x/apps/renderer/src/components/code/session-rail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
116
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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: '#000000',
|
||||
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"
|
||||
style={{ backgroundColor: resolvedTheme === 'dark' ? '#000000' : '#ffffff' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
511
apps/x/apps/renderer/src/components/code/use-code-chat.ts
Normal file
511
apps/x/apps/renderer/src/components/code/use-code-chat.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
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'])
|
||||
const COMPACTION_TITLE = 'Compacting context'
|
||||
const COMPACTION_STALLED_MS = 90_000
|
||||
|
||||
export type CompactionStatus = 'idle' | 'running' | 'stalled'
|
||||
|
||||
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 [compactionStatus, setCompactionStatus] = useState<CompactionStatus>('idle')
|
||||
const [contextUsage, setContextUsage] = useState<{ used: number; size: number } | null>(null)
|
||||
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 compactionToolIdRef = useRef<string | null>(null)
|
||||
|
||||
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('')
|
||||
setCompactionStatus('idle')
|
||||
setContextUsage(null)
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setCompactionStatus('idle')
|
||||
setContextUsage(null)
|
||||
compactionToolIdRef.current = null
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (compactionStatus !== 'running') return
|
||||
const timer = window.setTimeout(() => setCompactionStatus('stalled'), COMPACTION_STALLED_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [compactionStatus])
|
||||
|
||||
// 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)
|
||||
setCompactionStatus('idle')
|
||||
compactionToolIdRef.current = null
|
||||
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)
|
||||
setCompactionStatus('idle')
|
||||
compactionToolIdRef.current = null
|
||||
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 === 'usage') {
|
||||
setContextUsage({ used: event.event.used, size: event.event.size })
|
||||
}
|
||||
if (event.event.type === 'tool_call' && event.event.title === COMPACTION_TITLE) {
|
||||
compactionToolIdRef.current = event.event.id ?? null
|
||||
setCompactionStatus('running')
|
||||
}
|
||||
if (event.event.type === 'tool_call_update'
|
||||
&& event.event.id != null
|
||||
&& event.event.id === compactionToolIdRef.current) {
|
||||
compactionToolIdRef.current = null
|
||||
setCompactionStatus('idle')
|
||||
}
|
||||
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,
|
||||
compactionStatus,
|
||||
contextUsage,
|
||||
pendingPermission,
|
||||
pendingToolPermissions,
|
||||
pendingAskHumans,
|
||||
loading,
|
||||
send,
|
||||
stop,
|
||||
resolvePermission,
|
||||
respondToToolPermission,
|
||||
respondToAskHuman,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
254
apps/x/apps/renderer/src/components/code/workspace-pane.tsx
Normal file
254
apps/x/apps/renderer/src/components/code/workspace-pane.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
274
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
274
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleDot,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader,
|
||||
Pencil,
|
||||
Search,
|
||||
ShieldQuestion,
|
||||
Sparkles,
|
||||
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, title?: string) {
|
||||
if (title === 'Compacting context') return <Sparkles className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
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, row.title)}
|
||||
<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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
import { Suspense, lazy, useEffect, useRef, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
CloudDownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
UploadCloudIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { DocxEditorRef } from '@eigenpal/docx-editor-react'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
// The editor (and its CSS) is heavy and only needed when a .docx is open, so it
|
||||
// loads in its own chunk the first time a Word document is viewed.
|
||||
|
|
@ -16,6 +24,14 @@ interface DocxFileViewerProps {
|
|||
path: string
|
||||
}
|
||||
|
||||
type GoogleDocLink = {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
syncedAt: string
|
||||
remoteModifiedTime?: string
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
|
|
@ -51,6 +67,9 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [link, setLink] = useState<GoogleDocLink | null>(null)
|
||||
const [syncing, setSyncing] = useState<'up' | 'down' | null>(null)
|
||||
|
||||
const editorRef = useRef<DocxEditorRef>(null)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
|
@ -59,7 +78,7 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
const dirtyRef = useRef(false)
|
||||
const savingRef = useRef(false)
|
||||
|
||||
// Load the .docx bytes whenever the path changes.
|
||||
// Load the .docx bytes whenever the path changes or a sync-down reloads it.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoadState('loading')
|
||||
|
|
@ -87,10 +106,20 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
cancelled = true
|
||||
if (armTimerRef.current) clearTimeout(armTimerRef.current)
|
||||
}
|
||||
}, [path, reloadNonce])
|
||||
|
||||
// Is this file linked to a Google Doc? Drives the sync bar.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLink(null)
|
||||
void window.ipc.invoke('google-docs:getLink', { path })
|
||||
.then((res) => { if (!cancelled) setLink(res.link) })
|
||||
.catch((err) => { console.error('Failed to read Google Doc link:', err) })
|
||||
return () => { cancelled = true }
|
||||
}, [path])
|
||||
|
||||
// Serialize the current document and write it back to disk.
|
||||
const persist = async () => {
|
||||
const persist = useCallback(async () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || savingRef.current) return
|
||||
savingRef.current = true
|
||||
|
|
@ -115,7 +144,8 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
// A change landed while we were saving — flush it.
|
||||
if (dirtyRef.current) scheduleSave()
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path])
|
||||
|
||||
const scheduleSave = () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
|
|
@ -137,6 +167,66 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path])
|
||||
|
||||
// Write any pending edits to disk before a sync-up so we push the latest.
|
||||
const flushPendingSave = useCallback(async () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
if (dirtyRef.current || savingRef.current) {
|
||||
await persist()
|
||||
}
|
||||
}, [persist])
|
||||
|
||||
const handleSyncDown = useCallback(async () => {
|
||||
if (syncing) return
|
||||
setSyncing('down')
|
||||
try {
|
||||
await window.ipc.invoke('google-docs:refreshSnapshot', { path })
|
||||
// Reload the freshly-written bytes into the editor.
|
||||
armedRef.current = false
|
||||
dirtyRef.current = false
|
||||
setReloadNonce((n) => n + 1)
|
||||
const res = await window.ipc.invoke('google-docs:getLink', { path })
|
||||
setLink(res.link)
|
||||
toast.success('Pulled latest from Google Docs')
|
||||
} catch (err) {
|
||||
console.error('Sync down failed:', err)
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to pull from Google Docs')
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
}, [path, syncing])
|
||||
|
||||
const handleSyncUp = useCallback(async () => {
|
||||
if (syncing) return
|
||||
setSyncing('up')
|
||||
try {
|
||||
await flushPendingSave()
|
||||
let result = await window.ipc.invoke('google-docs:sync', { path })
|
||||
if (result.conflict) {
|
||||
const overwrite = window.confirm(
|
||||
'This Google Doc changed since your last sync.\n\n' +
|
||||
'Overwrite it with your local version? Cancel to keep the remote copy ' +
|
||||
'(use “Sync down” to pull it first).',
|
||||
)
|
||||
if (!overwrite) {
|
||||
toast.info('Sync up cancelled — remote Google Doc is unchanged')
|
||||
return
|
||||
}
|
||||
result = await window.ipc.invoke('google-docs:sync', { path, force: true })
|
||||
}
|
||||
if (!result.synced) {
|
||||
throw new Error(result.error || 'This file is not linked to a Google Doc.')
|
||||
}
|
||||
const res = await window.ipc.invoke('google-docs:getLink', { path })
|
||||
setLink(res.link)
|
||||
toast.success('Pushed changes to Google Docs')
|
||||
} catch (err) {
|
||||
console.error('Sync up failed:', err)
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to push to Google Docs')
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
}, [path, syncing, flushPendingSave])
|
||||
|
||||
if (loadState === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
|
|
@ -155,37 +245,75 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (loadState === 'loading' || !buffer) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading document…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading editor…</p>
|
||||
{link && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/30 px-3 py-1.5 text-xs">
|
||||
<GoogleDocsIcon className="size-4 shrink-0" />
|
||||
<span className="truncate font-medium text-foreground">{link.title}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{syncing
|
||||
? syncing === 'up' ? 'Syncing up…' : 'Syncing down…'
|
||||
: `Synced ${formatRelativeTime(link.syncedAt)}`}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleSyncDown() }}
|
||||
disabled={Boolean(syncing)}
|
||||
title="Pull latest from Google Docs"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<CloudDownloadIcon className="size-3.5" /> Sync down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleSyncUp() }}
|
||||
disabled={Boolean(syncing)}
|
||||
title="Push your changes to Google Docs"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<UploadCloudIcon className="size-3.5" /> Sync up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.open(link.url, '_blank') }}
|
||||
title="Open in Google Docs"
|
||||
className="inline-flex items-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyDocxEditor
|
||||
key={path}
|
||||
ref={editorRef}
|
||||
documentBuffer={buffer}
|
||||
mode="editing"
|
||||
documentName={baseName(path)}
|
||||
documentNameEditable={false}
|
||||
onChange={handleChange}
|
||||
onError={(err) => { console.error('docx editor error:', err) }}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'loading' || !buffer ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading document…</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading editor…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyDocxEditor
|
||||
key={`${path}:${reloadNonce}`}
|
||||
ref={editorRef}
|
||||
documentBuffer={buffer}
|
||||
mode="editing"
|
||||
documentName={baseName(path)}
|
||||
documentNameEditable={false}
|
||||
onChange={handleChange}
|
||||
onError={(err) => { console.error('docx editor error:', err) }}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{saveState !== 'idle' && (
|
||||
<div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
|
||||
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'}
|
||||
|
|
@ -194,3 +322,13 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GoogleDocsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
|
||||
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
|
||||
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
|
||||
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,17 +26,24 @@ import {
|
|||
Trash2Icon,
|
||||
ImageIcon,
|
||||
DownloadIcon,
|
||||
ChevronDownIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
CloudDownloadIcon,
|
||||
LoaderIcon,
|
||||
UploadCloudIcon,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
editor: Editor | null
|
||||
|
|
@ -45,6 +52,16 @@ interface EditorToolbarProps {
|
|||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
onOpenLiveNote?: () => void
|
||||
liveState?: LivePillState
|
||||
googleDoc?: GoogleDocToolbarState
|
||||
}
|
||||
|
||||
export interface GoogleDocToolbarState {
|
||||
title: string
|
||||
isSyncing?: 'up' | 'down' | null
|
||||
lastSyncedAt?: string
|
||||
onOpen: () => void
|
||||
onSyncDown: () => void
|
||||
onSyncUp: () => void
|
||||
}
|
||||
|
||||
export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error'
|
||||
|
|
@ -60,6 +77,36 @@ const LIVE_PILL_VARIANT_CLASS: Record<LivePillVariant, string> = {
|
|||
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
|
||||
}
|
||||
|
||||
function GoogleDocsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
|
||||
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
|
||||
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
editor,
|
||||
onSelectionHighlight,
|
||||
|
|
@ -67,6 +114,7 @@ export function EditorToolbar({
|
|||
onExport,
|
||||
onOpenLiveNote,
|
||||
liveState,
|
||||
googleDoc,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -404,6 +452,51 @@ export function EditorToolbar({
|
|||
</>
|
||||
)}
|
||||
|
||||
{googleDoc && (
|
||||
<>
|
||||
<div className="separator" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2"
|
||||
title={`Google Doc: ${googleDoc.title}`}
|
||||
disabled={Boolean(googleDoc.isSyncing)}
|
||||
>
|
||||
{googleDoc.isSyncing ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<GoogleDocsIcon className="size-4" />
|
||||
)}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="font-normal text-muted-foreground">
|
||||
{googleDoc.lastSyncedAt
|
||||
? `Last synced ${formatRelativeTime(googleDoc.lastSyncedAt)}`
|
||||
: 'Not synced yet'}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={googleDoc.onOpen}>
|
||||
<GoogleDriveIcon className="size-4 mr-2" />
|
||||
Open Google Doc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={googleDoc.onSyncDown} disabled={Boolean(googleDoc.isSyncing)}>
|
||||
<CloudDownloadIcon className="size-4 mr-2" />
|
||||
Sync down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={googleDoc.onSyncUp} disabled={Boolean(googleDoc.isSyncing)}>
|
||||
<UploadCloudIcon className="size-4 mr-2" />
|
||||
Sync up
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live Note pill — pushed to far right */}
|
||||
{onOpenLiveNote && liveState && (
|
||||
<button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
142
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal file
142
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FileText, Loader2, RefreshCw } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
type GoogleDocPickerDialogProps = {
|
||||
open: boolean
|
||||
targetFolder: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onImported: (path: string) => void
|
||||
}
|
||||
|
||||
export function GoogleDocPickerDialog({
|
||||
open,
|
||||
targetFolder,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: GoogleDocPickerDialogProps) {
|
||||
// The managed picker runs its own drive.file OAuth in the browser, gated on
|
||||
// the Rowboat web session. So the only desktop prerequisite is being signed
|
||||
// in to Rowboat — it needs NO prior Google connection and NO drive.file scope
|
||||
// on the main grant (the picker grants drive.file per-file as you choose).
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const account = await window.ipc.invoke('account:getRowboat', null)
|
||||
setSignedIn(account.signedIn)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setSignedIn(null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to check your Rowboat sign-in')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
void loadStatus()
|
||||
}, [loadStatus, open])
|
||||
|
||||
const handleChoose = useCallback(async () => {
|
||||
setError(null)
|
||||
setOpening(true)
|
||||
|
||||
// Managed pick: the Rowboat backend runs the whole grant + pick in the
|
||||
// browser with the company Google client, then deep-links the selection
|
||||
// back. No API key, BYOK creds, or redirect URL to configure. Close our
|
||||
// modal during the hand-off.
|
||||
onOpenChange(false)
|
||||
toast('Continue in your browser: grant access and pick a document…', 'info')
|
||||
let result: { path: string; doc: { name: string } } | null = null
|
||||
try {
|
||||
result = await window.ipc.invoke('google-docs:pickViaManaged', { targetFolder })
|
||||
} catch (err) {
|
||||
setOpening(false)
|
||||
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
setOpening(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast(`Added “${result.doc.name}”`, 'success')
|
||||
onImported(result.path)
|
||||
setOpening(false)
|
||||
}, [targetFolder, onImported, onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||
<DialogTitle>Add Google Doc</DialogTitle>
|
||||
<DialogDescription>
|
||||
Link a Google Doc or Word file from Drive into {targetLabel}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{signedIn === null && error ? (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<div className="max-w-sm text-sm text-destructive">{error}</div>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
<RefreshCw className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : signedIn === null ? (
|
||||
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Checking your Rowboat sign-in…
|
||||
</div>
|
||||
) : !signedIn ? (
|
||||
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||
<div className="max-w-sm text-sm text-muted-foreground">
|
||||
Sign in to Rowboat to add Google Docs from Drive. The picker uses your
|
||||
Rowboat account — no Google credentials or API key needed.
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
<RefreshCw className="size-4" />
|
||||
I've signed in — retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||
<div className="max-w-sm text-sm text-muted-foreground">
|
||||
Pick a Google Doc or Word file from your Drive. It imports as an editable
|
||||
<code> .docx</code> and stays linked for two-way sync.
|
||||
</div>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
You'll continue in your browser to grant access and choose a document — no
|
||||
API key or setup needed.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="w-full max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => void handleChoose()} disabled={opening}>
|
||||
{opening ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||
Choose from Google Drive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -38,6 +38,7 @@ interface TreeNode {
|
|||
|
||||
export type KnowledgeViewActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
addGoogleDoc: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
|
|
@ -46,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
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +109,21 @@ function latestMtime(node: TreeNode): number {
|
|||
return max
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
|
|
@ -145,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)
|
||||
|
|
@ -184,27 +206,46 @@ 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>
|
||||
</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 ? (
|
||||
|
|
@ -267,11 +308,12 @@ export function KnowledgeView({
|
|||
<QuickActions
|
||||
actions={actions}
|
||||
currentFolder={currentFolder}
|
||||
onOpenBases={onOpenBases}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onFolderCreated={setRenameTarget}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -279,12 +321,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/.
|
||||
|
|
@ -294,6 +336,8 @@ 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={GoogleDriveIcon} label="Add Google Doc" onClick={() => actions.addGoogleDoc(parent)} />
|
||||
<QuickAction icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<QuickAction
|
||||
icon={FolderPlus}
|
||||
label="New folder"
|
||||
|
|
@ -304,7 +348,6 @@ function QuickActions({
|
|||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
|
||||
<QuickAction
|
||||
icon={FolderOpen}
|
||||
label={`Reveal in ${getFileManagerName()}`}
|
||||
|
|
@ -315,20 +358,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>
|
||||
|
|
@ -341,7 +390,7 @@ function QuickAction({
|
|||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof FilePlus
|
||||
icon: typeof FilePlus | typeof GoogleDriveIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
|
|
@ -532,7 +581,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">
|
||||
|
|
@ -764,6 +813,10 @@ function RowContextMenu({
|
|||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.addGoogleDoc(node.path)}>
|
||||
<GoogleDriveIcon className="mr-2 size-4" />
|
||||
Add Google Doc
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ function computeWithinBlockOffset(
|
|||
return 0
|
||||
}
|
||||
}
|
||||
import { EditorToolbar, type LivePillState } from './editor-toolbar'
|
||||
import { EditorToolbar, type GoogleDocToolbarState, type LivePillState } from './editor-toolbar'
|
||||
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
|
|
@ -448,6 +448,7 @@ interface MarkdownEditorProps {
|
|||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
notePath?: string
|
||||
googleDoc?: GoogleDocToolbarState
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -645,6 +646,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
googleDoc,
|
||||
}, ref) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -1628,6 +1630,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
googleDoc={googleDoc}
|
||||
onOpenLiveNote={notePath ? () => {
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
|
||||
detail: { filePath: notePath },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { StepIndicator } from "./step-indicator"
|
|||
import { WelcomeStep } from "./steps/welcome-step"
|
||||
import { LlmSetupStep } from "./steps/llm-setup-step"
|
||||
import { ConnectAccountsStep } from "./steps/connect-accounts-step"
|
||||
import { CodeModeStep } from "./steps/code-mode-step"
|
||||
import { CompletionStep } from "./steps/completion-step"
|
||||
|
||||
interface OnboardingModalProps {
|
||||
|
|
@ -33,6 +34,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
case 2:
|
||||
return <ConnectAccountsStep state={state} />
|
||||
case 3:
|
||||
return <CodeModeStep state={state} />
|
||||
case 4:
|
||||
return <CompletionStep state={state} />
|
||||
}
|
||||
}, [state.currentStep, state])
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import type { Step, OnboardingPath } from "./use-onboarding-state"
|
|||
const ROWBOAT_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
interface StepIndicatorProps {
|
||||
|
|
@ -26,7 +28,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-8 px-4">
|
||||
<div className="flex items-center gap-2 mb-20 px-4">
|
||||
{steps.map((s, i) => (
|
||||
<React.Fragment key={s.step}>
|
||||
{i > 0 && (
|
||||
|
|
@ -37,7 +39,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
|
||||
|
|
@ -54,7 +56,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium transition-colors duration-300",
|
||||
"absolute top-full mt-1.5 whitespace-nowrap text-[11px] font-medium transition-colors duration-300",
|
||||
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, ArrowLeft, Terminal, CheckCircle2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { startProvisioning, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface CodeModeStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const AGENTS = [
|
||||
{ key: "claude" as const, name: "Claude Code" },
|
||||
{ key: "codex" as const, name: "Codex" },
|
||||
]
|
||||
|
||||
export function CodeModeStep({ state }: CodeModeStepProps) {
|
||||
const { handleNext, handleBack } = state
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [selected, setSelected] = useState<Record<"claude" | "codex", boolean>>({ claude: false, codex: false })
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reflect what's already set up: pre-select installed agents and turn the master
|
||||
// switch on if any agent is already there, so returning users don't start from off.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setStatusLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||
if (cancelled) return
|
||||
setStatus(result)
|
||||
const claudeInstalled = result.claude.installed
|
||||
const codexInstalled = result.codex.installed
|
||||
if (claudeInstalled || codexInstalled) {
|
||||
setEnabled(true)
|
||||
setSelected({ claude: claudeInstalled, codex: codexInstalled })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus(null)
|
||||
} finally {
|
||||
if (!cancelled) setStatusLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const onContinue = useCallback(async () => {
|
||||
if (enabled) {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: true, approvalPolicy: "ask" })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
} catch {
|
||||
// Non-fatal — the user can still enable code mode later from Settings.
|
||||
}
|
||||
setSaving(false)
|
||||
// Kick off engine downloads in the BACKGROUND for selected agents that aren't
|
||||
// installed yet. We deliberately don't block onboarding on the ~200 MB download —
|
||||
// it keeps running in the main process and its progress shows in Settings → Code Mode.
|
||||
for (const a of AGENTS) {
|
||||
if (selected[a.key] && !status?.[a.key].installed) {
|
||||
startProvisioning(a.key, () => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
handleNext()
|
||||
}, [enabled, selected, status, handleNext])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Set Up Code Mode
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center leading-relaxed mb-6 max-w-md mx-auto">
|
||||
Use your existing Claude Code or Codex subscription inside Rowboat to tackle coding
|
||||
tasks and unlock far more workflows, all without leaving the app. Make sure Claude Code
|
||||
and Codex are signed in locally with{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">claude login</code> or{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">codex login</code> in your terminal.
|
||||
</p>
|
||||
|
||||
{statusLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Master enable */}
|
||||
<div className="rounded-xl border px-4 py-3.5 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Enable code mode</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Shows the code mode chip in the composer and lets the assistant delegate to your agents.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} disabled={saving} />
|
||||
</div>
|
||||
|
||||
{/* Per-agent selection (revealed when enabled) */}
|
||||
{enabled && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Agents to set up
|
||||
</span>
|
||||
{AGENTS.map((a) => {
|
||||
const st = status?.[a.key]
|
||||
const ready = (st?.installed ?? false) && (st?.signedIn ?? false)
|
||||
return (
|
||||
<div key={a.key} className="rounded-xl border px-4 py-3 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 text-sm font-medium">{a.name}</div>
|
||||
{ready && <CheckCircle2 className="size-4 text-green-600 shrink-0" />}
|
||||
<Switch
|
||||
checked={selected[a.key]}
|
||||
onCheckedChange={(v) => setSelected((prev) => ({ ...prev, [a.key]: v }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
|
||||
<Button onClick={onContinue} size="lg" className="h-12 text-base font-medium" disabled={saving}>
|
||||
{saving ? <Loader2 className="size-5 animate-spin" /> : "Continue"}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,34 +13,25 @@ export function CompletionStep({ state }: CompletionStepProps) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Animated checkmark */}
|
||||
{/* Title with checkmark on the right */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
{/* Pulsing ring */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0.6 }}
|
||||
animate={{ scale: 1.5, opacity: 0 }}
|
||||
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
|
||||
className="absolute inset-0 rounded-full bg-green-500/20"
|
||||
/>
|
||||
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
className="flex items-center gap-3 mb-3"
|
||||
>
|
||||
You're All Set!
|
||||
</motion.h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
You're All Set!
|
||||
</h2>
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.35 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CheckCircle2 className="size-9 text-green-600 dark:text-green-400" />
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -12,15 +12,21 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Logo with ambient glow */}
|
||||
{/* Logo + main heading on the same level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative mb-8"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex items-center gap-4 mb-4"
|
||||
>
|
||||
<div className="absolute inset-0 size-16 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-16" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome to Rowboat
|
||||
</h1>
|
||||
{/* Logo with ambient glow */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 size-12 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-12" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tagline badge */}
|
||||
|
|
@ -28,21 +34,11 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
|
|||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6"
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-10"
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Your AI coworker, with memory
|
||||
</motion.div>
|
||||
|
||||
{/* Main heading */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
Welcome to Rowboat
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface ProviderState {
|
|||
isConnecting: boolean
|
||||
}
|
||||
|
||||
export type Step = 0 | 1 | 2 | 3
|
||||
export type Step = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -377,8 +377,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
}, [startGoogleCalendarConnect])
|
||||
|
||||
// New step flow:
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done)
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 0) {
|
||||
if (onboardingPath === 'byok') {
|
||||
|
|
@ -390,6 +390,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setCurrentStep(2)
|
||||
} else if (currentStep === 2) {
|
||||
setCurrentStep(3)
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(4)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
|
|
@ -403,6 +405,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
} else {
|
||||
setCurrentStep(1)
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
|
|
@ -410,43 +414,68 @@ 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 catalog: string[] = result.models ?? []
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
const model =
|
||||
(preferred && catalog.includes(preferred) && preferred) ||
|
||||
catalog[0] || activeConfig.model.trim() || ""
|
||||
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
// model; users add more from Settings. Persisting the whole catalog here
|
||||
// rendered every model as a separate assistant-model row.
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
|
||||
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 +661,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
showBaseURL,
|
||||
isLocalProvider,
|
||||
canTest,
|
||||
connectedFlavors,
|
||||
showMoreProviders,
|
||||
setShowMoreProviders,
|
||||
updateProviderConfig,
|
||||
handleTestAndSaveLlmConfig,
|
||||
handleTestAndAddAnother,
|
||||
|
||||
// OAuth state
|
||||
providers,
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
|
||||
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 +84,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 +218,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 +248,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 +329,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 +375,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 },
|
||||
|
|
@ -323,38 +385,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
[]
|
||||
)
|
||||
|
||||
const updateModelAt = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number, value: string) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = [...prev[prov].models]
|
||||
models[index] = value
|
||||
return { ...prev, [prov]: { ...prev[prov], models } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const addModel = useCallback(
|
||||
(prov: LlmProviderFlavor) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const removeModel = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = prev[prov].models.filter((_, i) => i !== index)
|
||||
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Load current config from file
|
||||
useEffect(() => {
|
||||
|
|
@ -388,6 +418,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||
meetingNotesModel: e.meetingNotesModel || "",
|
||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -406,6 +437,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
|
|
@ -481,6 +513,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 +548,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 +580,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 +588,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'))
|
||||
|
|
@ -661,48 +696,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeConfig.models.map((model, index) => (
|
||||
<div key={index} className="group/model relative">
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => updateModelAt(provider, index, e.target.value)}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => updateModelAt(provider, index, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{activeConfig.models.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeModel(provider, index)}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addModel(provider)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add assistant model
|
||||
</button>
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={primaryModel}
|
||||
onValueChange={(value) => updateConfig(provider, { models: [value] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{modelsError && (
|
||||
|
|
@ -811,6 +827,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 */}
|
||||
|
|
@ -1656,55 +1706,65 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
|
||||
// --- Code Mode Settings ---
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
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 & sign in
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1712,6 +1772,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 +1797,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 +1816,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 +1825,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 +1860,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 +1892,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 +1921,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 +1963,104 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Notification Settings ---
|
||||
|
||||
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task"
|
||||
|
||||
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.",
|
||||
},
|
||||
{
|
||||
key: "background_task",
|
||||
label: "Background agents",
|
||||
description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.",
|
||||
},
|
||||
]
|
||||
|
||||
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 +2108,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 +2216,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 +2239,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" ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
138
apps/x/apps/renderer/src/components/tool-connections-card.tsx
Normal file
138
apps/x/apps/renderer/src/components/tool-connections-card.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
|
|
@ -27,6 +29,7 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -55,10 +58,26 @@ type WorkspaceActions = {
|
|||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
createNote: (parentPath?: string) => void
|
||||
addGoogleDoc: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
tree: TreeNode[]
|
||||
initialPath?: string | null
|
||||
|
|
@ -68,6 +87,24 @@ type WorkspaceViewProps = {
|
|||
onNavigate: (path: string) => void
|
||||
onOpenNote: (path: string) => void
|
||||
onCreateWorkspace: (name: string) => Promise<void>
|
||||
// Opens a previous chat (run) whose work directory is set to this workspace.
|
||||
onOpenRun: (runId: string) => void
|
||||
}
|
||||
|
||||
type WorkspaceChat = {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
function formatChatAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
|
|
@ -126,9 +163,12 @@ function readFileAsBase64(file: File): Promise<string> {
|
|||
})
|
||||
}
|
||||
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onOpenRun }: WorkspaceViewProps) {
|
||||
const currentPath = initialPath || WORKSPACE_ROOT
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [chatsOpen, setChatsOpen] = useState(false)
|
||||
const [chats, setChats] = useState<WorkspaceChat[]>([])
|
||||
const [chatsLoading, setChatsLoading] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -165,6 +205,32 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
})
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
// Load the chats whose work directory is this workspace folder (or nested
|
||||
// inside it). The work directory is stored as an absolute path per run, so
|
||||
// resolve this folder against the workspace root before querying.
|
||||
const loadChats = useCallback(async () => {
|
||||
if (isRoot) {
|
||||
setChats([])
|
||||
return
|
||||
}
|
||||
setChatsLoading(true)
|
||||
try {
|
||||
const { root } = await window.ipc.invoke('workspace:getRoot', null)
|
||||
const abs = `${root.replace(/\/$/, '')}/${currentPath}`
|
||||
const { runs } = await window.ipc.invoke('runs:listByWorkDir', { dir: abs })
|
||||
setChats(runs.map((r) => ({ id: r.id, title: r.title, createdAt: r.createdAt, modifiedAt: r.modifiedAt })))
|
||||
} catch (err) {
|
||||
console.error('Failed to load workspace chats:', err)
|
||||
setChats([])
|
||||
} finally {
|
||||
setChatsLoading(false)
|
||||
}
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
useEffect(() => {
|
||||
void loadChats()
|
||||
}, [loadChats])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: TreeNode) => {
|
||||
if (renameTarget) return
|
||||
|
|
@ -335,30 +401,48 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isRoot && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={chatsOpen ? 'secondary' : 'outline'}
|
||||
onClick={() => setChatsOpen((v) => !v)}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Chats{chats.length ? ` (${chats.length})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => actions.revealInFileManager(currentPath, true)}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
Open in {fileManagerName}
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add workspace
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="w-full">
|
||||
<Button size="sm">
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => actions.createNote(currentPath)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New note
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => actions.addGoogleDoc(currentPath)}>
|
||||
<GoogleDriveIcon className="mr-2 size-4" />
|
||||
Add Google Doc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
Add files…
|
||||
|
|
@ -396,6 +480,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto px-6 py-6"
|
||||
onDragEnter={handleDragEnter}
|
||||
|
|
@ -523,6 +608,45 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!isRoot && chatsOpen && (
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">Chats</span>
|
||||
{chatsLoading && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!chatsLoading && chats.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
No chats use this workspace yet. Set a chat's work directory to this folder and it will appear here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{chats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
type="button"
|
||||
onClick={() => onOpenRun(chat.id)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-[13px] text-foreground">
|
||||
{chat.title || '(Untitled chat)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatChatAt(chat.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={addOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||
|
|
@ -21,8 +23,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 +150,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>('');
|
||||
|
|
@ -156,15 +197,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
}, 1000);
|
||||
}, [writeTranscriptToFile]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writeTimerRef.current) {
|
||||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
}
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = null;
|
||||
}
|
||||
const stopInputCapture = useCallback(() => {
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
|
|
@ -181,12 +214,32 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
systemStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
systemStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writeTimerRef.current) {
|
||||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.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;
|
||||
}
|
||||
stopInputCapture();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [stopInputCapture]);
|
||||
|
||||
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||
if (state !== 'idle') return null;
|
||||
|
|
@ -279,13 +332,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 +371,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 +441,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 +496,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 +509,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]);
|
||||
|
|
@ -406,12 +556,14 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
if (state !== 'recording') return;
|
||||
setState('stopping');
|
||||
|
||||
stopInputCapture();
|
||||
await finalizeDeepgramStream(wsRef.current, 2200);
|
||||
cleanup();
|
||||
interimRef.current = new Map();
|
||||
await writeTranscriptToFile();
|
||||
interimRef.current = new Map();
|
||||
|
||||
setState('idle');
|
||||
}, [state, cleanup, writeTranscriptToFile]);
|
||||
}, [state, cleanup, stopInputCapture, writeTranscriptToFile]);
|
||||
|
||||
return { state, start, stop };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening';
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening' | 'submitting';
|
||||
|
||||
const DEEPGRAM_PARAMS = new URLSearchParams({
|
||||
model: 'nova-3',
|
||||
|
|
@ -20,6 +21,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 +47,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 () => {
|
||||
|
|
@ -112,8 +130,45 @@ export function useVoiceMode() {
|
|||
};
|
||||
}, [refreshAuth]);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
const waitForWsOpen = useCallback(async (timeoutMs = 1500): Promise<boolean> => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws) return false;
|
||||
if (ws.readyState === WebSocket.OPEN) return true;
|
||||
if (ws.readyState !== WebSocket.CONNECTING) return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const finish = (ok: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('open', onOpen);
|
||||
ws.removeEventListener('error', onError);
|
||||
ws.removeEventListener('close', onClose);
|
||||
resolve(ok);
|
||||
};
|
||||
const onOpen = () => finish(true);
|
||||
const onError = () => finish(false);
|
||||
const onClose = () => finish(false);
|
||||
timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
ws.addEventListener('open', onOpen);
|
||||
ws.addEventListener('error', onError);
|
||||
ws.addEventListener('close', onClose);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flushBufferedAudio = useCallback(() => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const buffered = audioBufferRef.current;
|
||||
audioBufferRef.current = [];
|
||||
for (const chunk of buffered) {
|
||||
ws.send(chunk);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopInputCapture = useCallback(() => {
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
|
|
@ -126,17 +181,24 @@ export function useVoiceMode() {
|
|||
mediaStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
stopInputCapture();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
audioBufferRef.current = [];
|
||||
audioLevelsRef.current = [];
|
||||
audioPeakRef.current = 0;
|
||||
setInterimText('');
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setState('idle');
|
||||
}, []);
|
||||
}, [stopInputCapture]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state !== 'idle') return;
|
||||
|
|
@ -145,12 +207,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 +239,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 +252,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,18 +290,28 @@ 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 => {
|
||||
const submit = useCallback(async (): Promise<string> => {
|
||||
setState('submitting');
|
||||
stopInputCapture();
|
||||
|
||||
if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||
await waitForWsOpen();
|
||||
}
|
||||
flushBufferedAudio();
|
||||
await finalizeDeepgramStream(wsRef.current);
|
||||
|
||||
let text = transcriptBufferRef.current;
|
||||
if (interimRef.current) {
|
||||
text += (text ? ' ' : '') + interimRef.current;
|
||||
}
|
||||
text = text.trim();
|
||||
|
||||
stopAudioCapture();
|
||||
return text;
|
||||
}, [stopAudioCapture]);
|
||||
}, [flushBufferedAudio, stopAudioCapture, stopInputCapture, waitForWsOpen]);
|
||||
|
||||
/** Cancel recording without returning transcript */
|
||||
const cancel = useCallback(() => {
|
||||
|
|
@ -215,5 +323,5 @@ export function useVoiceMode() {
|
|||
refreshAuth().catch(() => {});
|
||||
}, [refreshAuth]);
|
||||
|
||||
return { state, interimText, start, submit, cancel, warmup };
|
||||
return { state, interimText, audioLevelsRef, start, submit, cancel, warmup };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
apps/x/apps/renderer/src/lib/code-mode-provisioning.ts
Normal file
63
apps/x/apps/renderer/src/lib/code-mode-provisioning.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
export type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
export type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
// Engine provisioning runs in the main process and keeps going even if the UI that
|
||||
// started it (the Settings dialog OR the onboarding step) unmounts. Track its state at
|
||||
// MODULE level — shared across both — so whichever view is mounted shows the live %
|
||||
// instead of restarting. This is what lets a download kicked off from onboarding show
|
||||
// its progress later in Settings → Code Mode. A single persistent listener on the
|
||||
// progress channel feeds this store.
|
||||
export 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).
|
||||
export const enabledOptimistic = new Set<string>()
|
||||
const provListeners = new Set<() => void>()
|
||||
let provChannelHooked = false
|
||||
|
||||
function notifyProv() { provListeners.forEach((l) => l()) }
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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]
|
||||
}
|
||||
33
apps/x/apps/renderer/src/lib/deepgram-finalize.ts
Normal file
33
apps/x/apps/renderer/src/lib/deepgram-finalize.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export async function finalizeDeepgramStream(ws: WebSocket | null, timeoutMs = 1800): Promise<void> {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('message', onMessage);
|
||||
resolve();
|
||||
};
|
||||
timeout = setTimeout(finish, timeoutMs);
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.is_final || data?.speech_final || data?.type === 'UtteranceEnd') {
|
||||
finish();
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON control frames.
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'Finalize' }));
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
|
|||
* re-emitted by buildFrontmatter (callers must splice them back from the
|
||||
* original raw if they want to preserve them on save — see the helpers below).
|
||||
*/
|
||||
const STRUCTURED_KEYS = new Set(['live'])
|
||||
const STRUCTURED_KEYS = new Set(['live', 'google_doc'])
|
||||
|
||||
/**
|
||||
* Extract editable top-level YAML key/value pairs from raw frontmatter.
|
||||
|
|
|
|||
|
|
@ -1551,9 +1551,9 @@
|
|||
/* Compose / draft box */
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose {
|
||||
margin-top: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1562,13 +1562,13 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-addr {
|
||||
|
|
@ -1592,7 +1592,7 @@
|
|||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-footer {
|
||||
|
|
@ -1600,7 +1600,7 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
|
|
@ -1615,35 +1615,33 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
width: fit-content;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
color: var(--foreground);
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary {
|
||||
color: #fff;
|
||||
background: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover {
|
||||
background: #1765cc;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
|
||||
color: #fff;
|
||||
background: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
border-color: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-error,
|
||||
|
|
@ -1690,9 +1688,8 @@
|
|||
|
||||
/* ---- Emails inbox block (language-emails) ---- */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-card {
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
/* No font-family override — inherit the editor's app font (ui-sans-serif /
|
||||
system-ui) so the inbox block matches the rest of the Rowboat UI. */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-title {
|
||||
font-size: 14px;
|
||||
|
|
@ -1768,7 +1765,6 @@
|
|||
.tiptap-editor .ProseMirror .email-inbox-sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
111
apps/x/packages/core/scripts/gen-engine-manifest.mjs
Normal file
111
apps/x/packages/core/scripts/gen-engine-manifest.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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,41 @@ 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) {
|
||||
// This generic completion ping is only for real user
|
||||
// chats (copilot_chat). Skip it for:
|
||||
// - knowledge_sync: an internal, auto-running agent
|
||||
// (knowledge-graph generation) that never notifies at
|
||||
// all and has no user-facing chat to "Open".
|
||||
// - background_task_agent: a user-configured agent that
|
||||
// DOES notify, but exclusively through its own
|
||||
// notify-user path; firing this ping too would
|
||||
// duplicate that notification.
|
||||
// (The finally block still runs on this early return.)
|
||||
if (
|
||||
finalState.runUseCase === "knowledge_sync" ||
|
||||
finalState.runUseCase === "background_task_agent"
|
||||
) return;
|
||||
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 +940,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 +952,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 +1061,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 +1074,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 +1125,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 +1219,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 +1248,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 +1319,9 @@ export async function* streamAgent({
|
|||
signal,
|
||||
abortRegistry,
|
||||
publish: (event) => bus.publish(event),
|
||||
codeMode,
|
||||
codeCwd,
|
||||
codePolicy,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1314,6 +1381,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 +1471,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 +1537,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 +1563,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 +1587,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
// Always build from the live skill set so disk skills added/removed at
|
||||
// runtime (after refreshDiskSkills + cache invalidation) are reflected.
|
||||
const catalog = buildSkillCatalog({ excludeIds });
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
// Always build from the live skill set so disk skills added/removed at
|
||||
// runtime (after refreshDiskSkills + cache invalidation) are reflected.
|
||||
const catalog = buildSkillCatalog({ excludeIds });
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint);
|
||||
const composioPrompt = await getComposioToolsPrompt(slackConnected);
|
||||
cachedInstructions = composioPrompt
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
: baseInstructions;
|
||||
|
|
|
|||
|
|
@ -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\`:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -100,7 +100,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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -53,6 +99,7 @@ import { getAccessToken } from "../../auth/tokens.js";
|
|||
import { API_URL } from "../../config/env.js";
|
||||
import type { IBrowserControlService } from "../browser-control/service.js";
|
||||
import type { INotificationService } from "../notification/service.js";
|
||||
import { notifyIfEnabled } from "../notification/notifier.js";
|
||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||
// Import paths are computed so esbuild cannot statically resolve them.
|
||||
|
|
@ -90,69 +137,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 +784,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 +801,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 +836,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 +856,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 +1533,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 +1563,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 +1608,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({
|
||||
|
|
@ -1524,13 +1660,44 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => {
|
||||
execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }, ctx?: ToolContext) => {
|
||||
try {
|
||||
const service = container.resolve<INotificationService>('notificationService');
|
||||
if (!service.isSupported()) {
|
||||
return { success: false, error: 'Notifications are not supported on this system' };
|
||||
}
|
||||
service.notify({ title, message, link, actionLabel, secondaryActions });
|
||||
let uc = getCurrentUseCase()?.useCase;
|
||||
// ALS doesn't reliably propagate across the run's async generator,
|
||||
// so when the in-context use-case is missing, fall back to the
|
||||
// persisted use case on the run record via ctx.runId.
|
||||
if (!uc && ctx?.runId) {
|
||||
try {
|
||||
const { fetchRun } = await import("../../runs/runs.js");
|
||||
const run = await fetchRun(ctx.runId);
|
||||
uc = run.useCase;
|
||||
} catch {
|
||||
// best effort — fall through to the default branch
|
||||
}
|
||||
}
|
||||
if (uc === 'background_task_agent') {
|
||||
// User-configured background agent: gate behind the
|
||||
// background_task category (toggleable), suppress the reopen
|
||||
// flood, and default the deep-link to the background tasks
|
||||
// page if the agent didn't supply its own link.
|
||||
await notifyIfEnabled('background_task', {
|
||||
title,
|
||||
message,
|
||||
link: link ?? 'rowboat://open?type=bg-tasks',
|
||||
actionLabel,
|
||||
secondaryActions,
|
||||
suppressDuringStartupGrace: true,
|
||||
onlyWhenBackground: true,
|
||||
});
|
||||
} else {
|
||||
// Regular chat (or any other) agent calling notify-user:
|
||||
// notify directly as before.
|
||||
service.notify({ title, message, link, actionLabel, secondaryActions });
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldSuppressDuringStartupGrace, STARTUP_GRACE_MS } from './service.js';
|
||||
|
||||
describe('shouldSuppressDuringStartupGrace (background-task reopen flood)', () => {
|
||||
const launchedAt = 1_700_000_000_000;
|
||||
|
||||
it('suppresses a grace-eligible notification fired inside the window', () => {
|
||||
const now = launchedAt + STARTUP_GRACE_MS - 1;
|
||||
expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a grace-eligible notification through once the window has passed', () => {
|
||||
const now = launchedAt + STARTUP_GRACE_MS;
|
||||
expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('never suppresses a notification that is not grace-eligible', () => {
|
||||
const now = launchedAt + 1; // well inside the window
|
||||
expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: false }, launchedAt, now)).toBe(false);
|
||||
expect(shouldSuppressDuringStartupGrace({}, launchedAt, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects a custom grace window', () => {
|
||||
const customWindow = 5_000;
|
||||
expect(
|
||||
shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, launchedAt + 4_999, customWindow),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, launchedAt + 5_000, customWindow),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,9 +4,48 @@ 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;
|
||||
/**
|
||||
* When true, the notification is suppressed if it fires within the startup
|
||||
* grace window (see STARTUP_GRACE_MS). This exists for notifications that a
|
||||
* just-launched app can emit in a burst — most notably background-task
|
||||
* completions: when the app reopens after being closed, every task that was
|
||||
* queued while it was down completes at once and would otherwise flood the
|
||||
* user. Fresh, user-driven activity happens after the window closes.
|
||||
*/
|
||||
suppressDuringStartupGrace?: boolean;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
isSupported(): boolean;
|
||||
notify(input: NotifyInput): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after launch grace-eligible notifications stay suppressed. Long
|
||||
* enough to swallow the reopen burst of queued background tasks, short enough
|
||||
* that a task genuinely finishing right after launch still pings the user.
|
||||
*/
|
||||
export const STARTUP_GRACE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Pure decision for the startup grace gate, kept out of the Electron service so
|
||||
* it can be unit-tested without an Electron runtime. Returns true when the
|
||||
* notification should be dropped because it is grace-eligible and we are still
|
||||
* inside the window measured from `launchedAt`.
|
||||
*/
|
||||
export function shouldSuppressDuringStartupGrace(
|
||||
input: Pick<NotifyInput, "suppressDuringStartupGrace">,
|
||||
launchedAt: number,
|
||||
now: number = Date.now(),
|
||||
graceWindowMs: number = STARTUP_GRACE_MS,
|
||||
): boolean {
|
||||
return Boolean(input.suppressDuringStartupGrace) && now - launchedAt < graceWindowMs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise<OAuthTokens>
|
|||
return toOAuthTokens(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim what the user selected in the managed OAuth-redirect Picker, parked
|
||||
* under `session` by the webapp picker callback. Returns the picked file ids
|
||||
* plus a fresh drive.file access token — the picker runs a standalone
|
||||
* drive.file authorization (the main connection doesn't carry drive.file), so
|
||||
* the desktop downloads the picked files with this token, not the main one.
|
||||
*/
|
||||
export async function claimPickedFilesViaBackend(
|
||||
session: string,
|
||||
): Promise<{ fileIds: string[]; accessToken: string }> {
|
||||
const res = await postWithBearer("/v1/google-oauth/claim-picked", { session });
|
||||
if (!res.ok) {
|
||||
const err = await readError(res);
|
||||
throw new Error(`claim picked files failed: ${res.status} ${err.error ?? ""}`.trim());
|
||||
}
|
||||
const body = (await res.json()) as { fileIds?: unknown; tokens?: { access_token?: unknown } };
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
const accessToken = typeof body.tokens?.access_token === "string" ? body.tokens.access_token : "";
|
||||
return { fileIds, accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token via the api. Preserves caller's `refreshToken` and
|
||||
* `existingScopes` when Google omits them on the refresh response.
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@ const providerConfigs: ProviderConfig = {
|
|||
scopes: [
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
// Per-file Drive access (non-restricted): the user grants read/write to a
|
||||
// specific doc by choosing it in the Google Picker. Enough to export/
|
||||
// download and write back, without the restricted full-drive scope.
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
],
|
||||
},
|
||||
'fireflies-ai': {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
333
apps/x/packages/core/src/background-tasks/code-sessions.ts
Normal file
333
apps/x/packages/core/src/background-tasks/code-sessions.ts
Normal 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 2–5 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 };
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getBackgroundTaskAgentModel } from '../models/defaults.js';
|
|||
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { buildTriggerBlock } from '../agents/build-trigger-block.js';
|
||||
import { backgroundTaskBus } from './bus.js';
|
||||
import { withUseCase } from '../analytics/use_case.js';
|
||||
|
||||
const log = new PrefixLogger('BgTask:Agent');
|
||||
|
||||
|
|
@ -31,11 +32,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 +71,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 +124,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 +164,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 +184,18 @@ export async function runBackgroundTask(
|
|||
});
|
||||
|
||||
try {
|
||||
await createMessage(runId, buildMessage(slug, task, trigger, context));
|
||||
// Establish the use-case context for the whole run so every tool the
|
||||
// agent calls (notably notify-user) reads `background_task_agent` via
|
||||
// getCurrentUseCase(). createMessage synchronously fires
|
||||
// agentRuntime.trigger(), so the detached run loop — and the tool
|
||||
// calls within it — inherit this AsyncLocalStorage context. (The
|
||||
// runtime's own enterUseCase runs inside an async generator and
|
||||
// doesn't reliably propagate to tool execution, so we set it here at
|
||||
// the trigger point instead.)
|
||||
await withUseCase(
|
||||
{ useCase: 'background_task_agent', subUseCase: trigger },
|
||||
() => createMessage(runId, buildMessage(slug, task, trigger, context, codeProject)),
|
||||
);
|
||||
await waitForRunCompletion(runId, { throwOnError: true });
|
||||
const summary = await extractAgentResponse(runId);
|
||||
|
||||
|
|
@ -151,6 +204,7 @@ export async function runBackgroundTask(
|
|||
lastRunAt: new Date().toISOString(),
|
||||
lastRunSummary: summary ?? undefined,
|
||||
lastRunError: undefined,
|
||||
...heal,
|
||||
});
|
||||
|
||||
log.log(`${slug} — done summary="${truncate(summary)}"`);
|
||||
|
|
@ -171,7 +225,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
102
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
102
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal 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 };
|
||||
}
|
||||
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal 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;
|
||||
}
|
||||
348
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
348
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
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,
|
||||
})),
|
||||
};
|
||||
case 'usage_update':
|
||||
return { type: 'usage', used: update.used, size: update.size };
|
||||
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 {};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue