Merge pull request #686 from rowboatlabs/dev

Dev changes
This commit is contained in:
Ramnique Singh 2026-07-07 18:56:10 +05:30 committed by GitHub
commit 50beb9f33e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
353 changed files with 56361 additions and 4785 deletions

View file

@ -163,12 +163,25 @@ jobs:
run: pnpm install --frozen-lockfile
working-directory: apps/x
- name: Build node-pty native binary for Linux
working-directory: apps/x
run: |
# node-pty ships prebuilt binaries only for darwin/win32; compile the
# linux-x64 binary so bundle.mjs can stage it into the package. Without
# this the Linux app crashes on launch (missing prebuilds/linux-x64/pty.node).
PTY="node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty"
cd "$PTY"
npx node-gyp rebuild
mkdir -p prebuilds/linux-x64
cp build/Release/pty.node prebuilds/linux-x64/
- name: Build electron app
env:
VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}
VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx electron-forge publish --arch=x64,arm64 --platform=linux
ROWBOAT_SKIP_PACMAN: '1' # Arch Linux package is built locally only, never in CI
run: npx electron-forge publish --arch=x64 --platform=linux
working-directory: apps/x/apps/main
- name: Upload workflow artifacts

40
.github/workflows/x-tests.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: apps/x tests
on:
pull_request:
paths:
- "apps/x/**"
- ".github/workflows/x-tests.yml"
push:
branches: [main]
paths:
- "apps/x/**"
- ".github/workflows/x-tests.yml"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/x
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: apps/x/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Builds @x/shared (core/renderer tests import its dist), then runs
# the shared, core, and renderer vitest suites in order.
- name: Run tests
run: npm test

3
.gitignore vendored
View file

@ -4,3 +4,6 @@
data/
.venv/
.claude/
# Local-only meeting-prep planning doc
/PLAN.md

View file

@ -109,7 +109,9 @@ Long-form docs for specific features. Read the relevant file before making chang
| Feature | Doc |
|---------|-----|
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
| Calls (video mode) — one hands-free call engine with four presets (voice / video / share screen / practice coaching), device-derived surfaces (full-screen ⇄ floating popout), frame pipeline, prompts catalog | `apps/x/VIDEO_MODE.md` |
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
## Common Tasks

145
README.md
View file

@ -1,9 +1,8 @@
<a href="https://www.youtube.com/watch?v=5AWoGo-L16I" target="_blank" rel="noopener noreferrer">
<img width="1339" height="607" alt="rowboat-github-2" src="https://github.com/user-attachments/assets/fc463b99-01b3-401c-b4a4-044dad480901" />
</a>
<h5 align="center">
<h1 align="center">Rowboat</h1>
<p align="center">A desktop AI coworker with a memory of your work and built-in surfaces to act on it.</p>
<p align="center" style="display: flex; justify-content: center; gap: 20px; align-items: center;">
<a href="https://trendshift.io/repositories/13609" target="blank">
<img src="https://trendshift.io/api/badge/repositories/13609" alt="rowboatlabs/rowboat | Trendshift" width="250" height="55"/>
@ -25,28 +24,105 @@
</a>
</p>
# Rowboat
**Open-source AI coworker that turns work into a knowledge graph and acts on it**
</h5>
Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine.
Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects.
You can do things like:
- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
- Track a person, company or topic through live notes
- Visualize, edit, and update your knowledge graph anytime (its just Markdown)
- Record voice memos that automatically capture and update key takeaways in the graph
Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
<p align="center">
<a href="https://www.youtube.com/watch?v=et5yQABJ3xI">
<img width="1091" height="632" alt="Rowboat demo video" src="https://github.com/user-attachments/assets/440087ae-5674-427a-87bb-831b0bea7de4" />
</a>
</p>
<p align="center">
<a href="https://www.youtube.com/watch?v=et5yQABJ3xI"> Demo - apps to code </a> · <a href="https://www.youtube.com/watch?v=7xTpciZCfpw"> Demo - knowledge graph</a>
</p>
⭐ If you find Rowboat useful, please star the repo. It helps more people find it.
## Demo
[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw)
---
## Overview
[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw)
<table>
<tr>
<td width="40%" valign="middle">
<h3>Brain</h3>
Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
</td>
<td width="60%">
<img width="1499" height="940" alt="Screenshot 2026-06-24 at 11 22 52PM" src="https://github.com/user-attachments/assets/203ca1d5-2f40-43dd-8da7-c5cef4e31a41" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Email</h3>
The built-in email client sorts emails into important and everything else. Rowboat automatically drafts responses for important email using all the work context.
</td>
<td width="60%">
<img width="1512" height="948" alt="Email screenshot" src="https://github.com/user-attachments/assets/4392b3ca-cc4c-473a-849a-eea0e97388f2" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Background agents</h3>
You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex.
</td>
<td width="60%">
<img width="1512" height="951" alt="Background agents screenshot" src="https://github.com/user-attachments/assets/5b73a3c3-f0d3-4151-83e7-0997457074e6" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Built-in Browser</h3>
Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access.
</td>
<td width="60%">
<img width="1512" height="948" alt="Browser screenshot" src="https://github.com/user-attachments/assets/ce04871f-4477-40eb-8310-13ef7f125b11" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Meeting Notes</h3>
A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph.
</td>
<td width="60%">
<img width="1512" height="947" alt="Meeting notes screenshot" src="https://github.com/user-attachments/assets/c3729952-3c75-4c84-88e0-2a9070136502" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Code Mode</h3>
Code mode lets you spin up parallel coding agents with Claude Code or Codex, and have Rowboat drive them with all the work context where needed.
</td>
<td width="60%">
<img width="1512" height="949" alt="Code mode screenshot" src="https://github.com/user-attachments/assets/306618b5-9aaf-4ef8-9117-91ea58e5e4e7" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Apps</h3>
You can bulild your own work surfaces inside Rowboat — they get acess to all the tools and integrations, and you can share them with other people.
</td>
<td width="60%">
<img width="1512" height="949" alt="Apps screenshot" src="https://github.com/user-attachments/assets/aaedc79b-3e72-45e8-995c-e8a3d127fdac" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Integrations</h3>
Includes one-click integrations to most popular products.
</td>
<td width="60%">
<img width="1512" height="948" alt="Integrations screenshot" src="https://github.com/user-attachments/assets/402e89db-8229-468a-8881-a763b9f20ad9" />
</td>
</tr>
</table>
---
@ -81,23 +157,6 @@ All API key files use the same format:
}
```
## What it does
Rowboat is a **local-first AI coworker** that can:
- **Remember** the important context you dont want to re-explain (people, projects, decisions, commitments)
- **Understand** whats relevant right now (before a meeting, while replying to an email, when writing a doc)
- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides)
Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit.
## Integrations
Rowboat builds memory from the work you already do, including:
- **Gmail** (email)
- **Google Calendar**
- **Rowboat meeting notes** or **Fireflies**
It also contains a library of product integrations through Composio.dev
## How its different
@ -111,24 +170,6 @@ Rowboat maintains **long-lived knowledge** instead:
The result is memory that compounds, rather than retrieval that starts cold every time.
## What you can do with it
- **Meeting prep** from prior decisions, threads, and open questions
- **Email drafting** grounded in history and commitments
- **Docs & decks** generated from your ongoing context (including PDF slides)
- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
## Live notes
Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note.
- Track a competitor or market topic across X, Reddit, and the news
- Monitor a person, project, or deal across web or your communications
- Keep a running summary of any subject you care about
Everything is written back into your local Markdown vault. You control what runs and when.
## Bring your own model
Rowboat works with the model setup you prefer:

1
apps/x/.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/
test-fixtures/
*.tsbuildinfo

Binary file not shown.

View file

@ -24,7 +24,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run
| Property | Type | Notes |
|---|---|---|
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` |
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
| `model` | string | e.g. `claude-sonnet-4-6` |
@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase:
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) |
##### `live_note_agent` sub-use-case shape
@ -91,6 +92,8 @@ All in `apps/renderer/src/lib/analytics.ts`:
- `chat_message_sent``{ voice_input, voice_output, search_enabled }`
- `oauth_connected` / `oauth_disconnected``{ provider }`
- `voice_input_started` — no properties
- `call_started``{ preset: 'voice' | 'video' | 'share' | 'practice' }` — a hands-free call began (see `apps/x/VIDEO_MODE.md`)
- `call_turn_latency``{ endpoint_to_submit_ms, submit_to_speak_ms, speak_to_audio_ms, total_ms }` — voice-to-voice latency breakdown for one call turn (utterance accepted → submitted → first TTS speak → audio playing)
- `search_executed``{ types: string[] }`
- `note_exported``{ format }`

View file

@ -0,0 +1,271 @@
# Code Mode — Managed Engine Provisioning Plan
Branch: `feat/code-mode-managed-engines` (off `dev` @ `8ce24ebb`)
## 1. Problem & Goal
Code mode runs two coding agents — **Claude Code** and **Codex** — by spawning their
ACP adapters, which in turn spawn a heavy **native engine binary** (~205 MB claude,
~194 MB codex). We need code mode to work in **packaged releases with ~99% reliability
for both agents**, without shipping a ~400 MB installer.
### Current state (HEAD = revert of #614)
- Packaged builds **do not stage** the ACP adapters, and `forge.config.cjs`
`ignore: /^\/node_modules\//` strips them. At runtime `agents.ts` resolves the
adapter via `require.resolve(...)` then spawns it — which **throws
`Cannot find module '@agentclientprotocol/...'`**.
- **Net: packaged code mode is broken in every release.** It only works in `dev`
because pnpm symlinks exist. There is no 400 MB bloat today — but no function either.
### Why the two prior approaches are insufficient
- **Bundle engines (A):** +~400 MB per installer (one claude + one codex native binary
per OS/arch). Works offline, but installer is huge.
- **Drive from user's local install (B)** — what #614 settled on and was reverted:
requires the user to have **both** CLIs installed **and** logged in, correct version,
on the right PATH. Depends on the user's machine → **structurally cannot hit 99%**
(version skew, GUI-launch PATH stripping, missing installs). This is why #614 was
reverted.
## 2. Chosen Architecture — Managed Engine Provisioning (validated against Conductor)
Split the problem into **engine** vs **auth**, treat them differently — exactly what
Conductor (conductor.build) does:
1. **Engine = owned by the app.** We provision **version-pinned** engine binaries into
**app-support** (`~/.rowboat/engines/<agent>/<version>/`), download-on-demand on first
use, sha256-verified, symlink/path-pinned. Not the user's global npm, not their PATH.
→ no version skew, no PATH quirks → this is what delivers the 99%.
2. **Auth = reused from the user.** The engines read existing credentials
(`~/.claude` API key / Pro / Max, `~/.codex` auth.json). No second login. `status.ts`
already inspects these.
### Empirical proof from Conductor on this machine
- DMG is **123 MB** — far smaller than the ~400 MB of engines → engines are **not** in
the installer; they're downloaded after install.
- Layout observed at `~/Library/Application Support/com.conductor.app/`:
```
agent-binaries/claude/2.1.170/claude (222 MB, single Mach-O arm64)
agent-binaries/codex/0.138.0/codex (205 MB, single Mach-O arm64)
bin/claude -> agent-binaries/claude/2.1.170/claude (symlink to active version)
bin/codex -> agent-binaries/codex/0.138.0/codex
agent-binaries/.meta/claude-2.1.170.json = {sha256, size, downloaded_at_unix_ms, ...}
```
- So: versioned dirs + stable symlink + sha256 `.meta` ledger + download. We mirror this.
## 3. Concrete facts that make this clean (verified)
### Adapters honor an external engine via env var (no code change in adapters needed)
- **Claude**`@agentclientprotocol/claude-agent-acp@0.39.0`,
`dist/acp-agent.js:39`: `if (process.env.CLAUDE_CODE_EXECUTABLE) return it;`
and line 1552 `pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? ...`.
If unset and no bundled native dep → it throws "set CLAUDE_CODE_EXECUTABLE".
- **Codex**`@agentclientprotocol/codex-acp@0.0.44`,
`dist/index.js:20900`: `const codexPath = process.env["CODEX_PATH"] ?? "codex";`
`spawn(codexPath, ["app-server"])`.
- **Implication:** provisioning + setting these two env vars is the *entire* engine story.
### Our engine packages are already single self-contained binaries
- `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156` → contains one `claude`
executable (+ LICENSE/README).
- `@openai/codex@0.128.0-darwin-arm64``vendor/<target>/codex/codex` native binary
**plus a bundled `rg` (ripgrep) at `vendor/<target>/path/rg`** — see Risk R1.
### Pinned versions + platform package names (read from the installed adapter trees)
- **Claude:** adapter `claude-agent-acp@0.39.0` → engine `@anthropic-ai/claude-agent-sdk@0.3.156`.
Platform optional deps `@anthropic-ai/claude-agent-sdk-<platform>@0.3.156`:
`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linux-x64-musl`,
`linux-arm64-musl`, `win32-x64`, `win32-arm64`.
- **Codex:** adapter `codex-acp@0.0.44``@openai/codex@^0.128.0` (pnpm-**patched**, see R2).
Platform deps aliased `@openai/codex-<platform>``npm:@openai/codex@0.128.0-<platform>`:
`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`.
## 4. Distribution source — npm platform packages, adapter-pinned (DECIDED)
We fetch the **per-platform engine packages from the npm registry at the exact versions
our ACP adapters depend on**, extract the native binary, and provision it into
`~/.rowboat/engines/...`. No self-host, no curl installer, no fallback.
- **Tarball URL:** `https://registry.npmjs.org/<pkg>/-/<file>-<version>.tgz`
- claude: `@anthropic-ai/claude-agent-sdk-<platform>@0.3.156` → extract the `claude` binary.
- codex: `@openai/codex@0.128.0-<platform>` → extract `vendor/<target>/codex/codex`
**and keep `vendor/<target>/path/rg`** (see R1).
- **Why adapter-pinned npm (not curl installer, not release bucket, not self-host):**
- The binary is the **exact version our adapter was built/tested against** → ACP
handshake guaranteed → the key to ~99% reliability.
- npm registry is highly available, immutable per-version, and the packument provides
`dist.integrity` (sha512) + `dist.shasum` (sha1) → integrity verification with **zero
infra**.
- The official `curl | bash` installer is for end-users: it installs globally to
`~/.local/bin` and **auto-updates in the background** — exactly what we must avoid. We
want an isolated, pinned, app-managed copy that never moves under the adapter.
(Conductor likewise fetches the raw binary rather than running the installer.)
- **Versions are read from our lockfile at build time** and embedded in
`engine-manifest.json`, so the manifest can never drift from the adapters we ship.
### Reference: how this relates to Conductor / official installers
- Conductor self-hosts the same kind of native binaries on `storage.conductor.build`
(raw/`.gz`/`.zst` + `{url,gzipUrl,zstdUrl,sha256}` manifest), pairing the adapters with
recent **standalone CLI** versions (claude 2.1.x, codex 0.138). That proves recent
versions work — but we deliberately pin to the **adapter's own** engine version for
determinism.
- Official Claude releases also publish a GPG-signed `manifest.json` (SHA256/platform) at
`downloads.claude.ai/claude-code-releases/<ver>/`. We don't use it now (npm is simpler
and adapter-matched), but it's the upgrade path if we ever want the latest CLI line.
- If we later want control/availability/compression, we can mirror these npm tarballs to
our own bucket — **runtime stays identical, only manifest URLs change.**
### Locked decisions
- **Source: npm platform packages, adapter-pinned versions (user).**
- **No self-host (user).**
- **Always provision; no fallback** to a user's pre-installed `claude`/`codex` (user).
Reverted path B is fully retired.
- **Codex pnpm patch — IRRELEVANT (user):** it targets the JS launcher; we point
`CODEX_PATH` at the native binary, so it does not apply. (Risk R2 removed.)
## 5. Design
### 5.1 Build-time: generate an engine manifest + stage adapter JS
**(a) Engine manifest (`engine-manifest.json`, embedded in the app).**
A build script reads the installed adapter dependency trees and emits, per agent:
```jsonc
{
"claude": {
"version": "0.3.156",
"platforms": {
"darwin-arm64": { "pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"tarball": "https://registry.npmjs.org/.../-/...-0.3.156.tgz",
"integrity": "sha512-...",
"binRelPath": "claude" },
"...": {}
}
},
"codex": {
"version": "0.128.0",
"platforms": {
"darwin-arm64": { "pkg": "@openai/codex-darwin-arm64",
"tarball": "https://registry.npmjs.org/...",
"integrity": "sha512-...",
"binRelPath": "vendor/aarch64-apple-darwin/codex/codex",
"extraPaths": ["vendor/aarch64-apple-darwin/path/rg"] }
}
}
}
```
- Versions + tarball URLs + integrity are pulled from the lockfile / npm packument so the
manifest is always in sync with the adapters we ship. (Generating it at build time
sidesteps hardcoding fragile package names.)
- `binRelPath` / `extraPaths` capture where the executable (and codex's `rg`) live inside
each tarball.
**(b) Stage the ACP adapter JS into the package (the part of #614 we KEEP).**
The adapters themselves (tiny, ~15 MB total incl. their non-native JS deps) must exist on
disk in packaged builds so `agents.ts` can resolve + spawn them. In `forge.config.cjs`
`generateAssets`, stage the two adapters and their **non-native** production dependency
closure into `.package/acp/node_modules` (npm-style nested layout), and **exempt
`.package` from the `node_modules` ignore rule**. We **drop** #614's native-engine staging
entirely — engines come from provisioning, not the bundle.
### 5.2 Runtime: engine provisioner (new module in `packages/core`)
New file: `packages/core/src/code-mode/acp/engine-provisioner.ts`.
```
ensureEngine(agent): Promise<{ executablePath: string }>
1. Read manifest entry for (agent, currentPlatform). Error clearly if unsupported.
2. dir = ~/.rowboat/engines/<agent>/<version>/
3. If dir exists AND .meta/<agent>-<version>.json sha256 matches → return binPath.
4. Else acquire a cross-process lock (avoid double download), then:
a. Download tarball to a temp file, streaming, with progress events.
b. Verify integrity (sha512/sha256 from manifest).
c. Extract into a temp dir, then atomic rename into <version>/ (tar gzip).
d. chmod +x the binary (and codex's rg) on unix.
e. Write .meta/<agent>-<version>.json {sha256, size, downloaded_at_unix_ms}.
5. Return absolute path to the engine executable (binRelPath joined to dir).
```
- **Progress + cancellation** surfaced over IPC for the first-run "Downloading engine…" UI.
- **Offline / failure** → typed error with a clear, actionable message (not a hang).
- **Resumability / atomicity:** download to temp, verify, then rename; never leave a
half-extracted version dir that passes the existence check.
### 5.3 Wire provisioning into launch
In `agents.ts` `getAgentLaunchSpec()` (currently sets only `CLAUDE_CODE_EXECUTABLE`):
- Make it (or its caller in `client.ts` / `manager.ts`) `await ensureEngine(agent)` first,
then set:
- claude → `env.CLAUDE_CODE_EXECUTABLE = <provisioned claude>`
- codex → `env.CODEX_PATH = <provisioned codex>` (and ensure its `rg` sibling resolves;
keep the vendor dir layout intact so codex finds `../path/rg`).
- Keep `ELECTRON_RUN_AS_NODE=1` for the adapter spawn (unchanged).
- The provisioner replaces both the bundled-engine dependency *and* the reverted
"resolve user's local install" path. (Optionally: if a healthy provisioned engine is
absent but a compatible local install exists, we *may* fall back to it — but the default
and reliable path is the provisioned engine. Decide in review; default = provisioned only.)
### 5.4 Status & UX (`status.ts` + renderer)
- `checkCodeModeAgentStatus()` becomes: engine = provisioned? (instead of "installed on
PATH?"); auth = existing credentials present? (unchanged logic).
- First-run flow: user picks agent → if engine missing, show "Downloading <agent> engine
(~200 MB), one time…" with progress; on completion, proceed. Subsequent uses: instant.
- Clear error states: download failed / offline / unsupported platform / auth missing.
## 6. File-by-file change plan
| File | Change |
|---|---|
| `apps/main/forge.config.cjs` | Stage adapter JS closure into `.package/acp/node_modules`; exempt `.package` from node_modules ignore; generate + copy `engine-manifest.json`. (No native engines staged.) |
| `apps/main/scripts/gen-engine-manifest.mjs` *(new)* | Build-time: read lockfile/packuments → emit `engine-manifest.json` (versions, tarball URLs, integrity, bin paths). |
| `packages/core/src/code-mode/acp/engine-provisioner.ts` *(new)* | `ensureEngine()` — download, verify, extract, lock, progress, `.meta` ledger. |
| `packages/core/src/code-mode/acp/agents.ts` | Resolve adapter from staged `.package/acp` first (fallback to node_modules in dev); `await ensureEngine`; set `CLAUDE_CODE_EXECUTABLE`/`CODEX_PATH` to provisioned binaries. |
| `packages/core/src/code-mode/acp/client.ts` / `manager.ts` | Await provisioning before spawn; surface provisioning progress/errors; keep startup deadline. |
| `packages/core/src/code-mode/status.ts` | Engine status = provisioned (not PATH); keep auth checks. |
| `apps/main/src/ipc.ts` + preload + renderer | IPC for provisioning progress + first-run download UI + error states. |
| CI (optional) | Smoke: packaged app boots each adapter, provisions a (small fake) engine, sets env var, answers ACP `initialize`; offline → clear error not a hang. |
## 7. Edge cases & risks
- **R1 — Codex needs `rg`.** The codex platform package bundles ripgrep at
`vendor/<target>/path/rg`. We must extract/keep the vendor layout so codex finds it
(don't extract the bare binary). Verify codex `app-server` boots from the provisioned dir.
- **R2 — Codex pnpm patch. RESOLVED (irrelevant).** The patch targets the JS launcher; we
point `CODEX_PATH` at the native binary, so it does not apply.
- **R3 — Platform/arch matrix.** Manifest must cover darwin x64/arm64, linux x64/arm64
(+musl for claude), win32 x64/arm64. Windows engine is `claude.exe`/`codex.exe`.
- **R4 — Integrity & supply chain.** Always verify the manifest integrity hash before
chmod/exec. Treat a hash mismatch as a hard failure.
- **R5 — Disk + upgrades.** Versioned dirs accumulate. Add a cleanup that keeps only the
active pinned version per agent.
- **R6 — First-run network.** Required once per agent; cached forever after. Must be a
clear, cancellable UX, never a silent hang (reuse the #614 startup-deadline lesson).
- **R7 — code signing / Gatekeeper (macOS).** Downloaded native binaries aren't covered by
our app's signature. Verify they run under Gatekeeper (they're already
signed/notarized by Anthropic/OpenAI; quarantine attr may need clearing). Conductor runs
them fine from app-support — confirm we do too.
- **R8 — `engine-manifest` staleness.** Manifest must regenerate whenever the adapter/engine
versions change; tie generation into the build so it can't drift.
## 8. Phasing
1. **P1 — Packaging fix (unblocks dev→packaged parity):** stage adapter JS into `.package`,
resolver checks staged path first. Verify packaged code mode can at least *spawn* the
adapter. (Independent of provisioning; the part of #614 worth keeping.)
2. **P2 — Provisioner (core):** `ensureEngine()` + manifest + wire env vars. Verify both
engines provision + boot from `~/.rowboat/engines` on macOS arm64.
3. **P3 — UX + status:** first-run download UI, status panel, error states.
4. **P4 — Cross-platform + CI smoke:** matrix manifest, mac/linux/win smoke.
5. **P5 — Polish:** version cleanup, cancellation, offline messaging, optional local-install
fallback.
## 9. Decisions & remaining questions
### Resolved (locked)
1. **Source = npm platform packages, adapter-pinned versions** (not self-host, not curl
installer, not release bucket).
2. **Always provision; no local-install fallback.**
3. **Codex pnpm patch irrelevant.**
### Still open (can decide during implementation)
- **Provision timing:** on first code-mode *use* (lazy) vs first app launch (eager,
background download). Plan assumes **lazy + cached**.
- **Version-dir cleanup:** keep only the active pinned version per agent (R5).
- **Verify R1** (codex `rg`) and **R7** (Gatekeeper on downloaded macOS binaries) during P2.

307
apps/x/MINI_APPS_PLAN.md Normal file
View file

@ -0,0 +1,307 @@
# Mini Apps — Implementation Plan
> Status: **Phase 1 (UI + rendering, hand-coded apps)** in progress.
> Branch: `feature/mini-apps`. Working dir: `apps/x`.
## 1. What we're building (agreed design)
A **Mini App** is a user-created, personal app that lives inside Rowboat under a
**"Mini Apps"** sidebar tab, shown as cards; click a card to open and use it like
a standalone app.
The settled mental model:
> A Mini App = **custom UI code** (a single self-contained HTML file: React +
> Tailwind, no build step, runs in a sandboxed iframe) + an **agent "backend"**
> that produces fresh **data** on a trigger + an optional **state store** + a
> **scoped bridge** that lets the UI call real **Composio** actions.
Key decisions (the "why" behind the architecture):
- **Opens to a finished, populated screen.** The agent runs *before* open (on a
trigger); the UI just renders the latest result. Maps onto the existing
background-agents engine.
- **Frontend/backend split.** Code = frontend, written once. Agent = backend,
produces fresh `data.json` each run. The UI is *not* regenerated per run.
Cheaper, faster, and the UI can't break on refresh.
- **A data contract** ties the three pieces together: at creation the builder
produces a matched triple — UI code + data schema + agent instructions.
- **Fully custom UI per app** (generated code), not a fixed block kit.
- **Real interactivity** via a **scoped bridge** to Composio (each app declares
the integrations it may touch; scope drives both enforcement and the auth
prompt).
- **Built by whatever engine is selected in the chat box** — Code Mode
(Claude Code / Codex) if the user has it, else the Copilot.
- **Living apps:** refined by chatting; breakage is first-class (app surfaces
errors → "fix with copilot").
- **State is an optional provided primitive** (per-app persistent store the agent
and UI can read/write). Stateless apps ignore it.
- **Personal/local only** for now, but each app is a **self-contained folder**
so it can become portable later.
### Framework for the apps
Single self-contained `index.html` rendered in a sandboxed iframe.
> **Learning (Phase 1):** remote CDNs do NOT work inside the sandboxed,
> opaque-origin `srcdoc` iframe (React/Tailwind/Babel from unpkg/cdn.tailwindcss
> failed → blank screen). Apps must be **fully self-contained, no network**.
> Phase 1 therefore ships **vanilla HTML + CSS + JS** (no build, no transpile).
> The React+Tailwind LLM-friendly target still stands for generation — but via a
> **locally-bundled** runtime (esbuild-at-save, libs injected into the HTML),
> never remote CDNs. The `window.rowboat` bridge is unchanged either way.
The UI talks to the host through a `window.rowboat` **bridge** (the product
surface *and* the security boundary):
```
rowboat.getData(): Promise<Data> // latest agent output
rowboat.onData(cb): void // re-render on refresh
rowboat.getState() / setState(patch) // per-app persistent store
rowboat.callAction(scope, action, args) // scoped Composio call
rowboat.ready() // handshake: "send me data"
```
## 2. Phased roadmap
1. **Surface + one real app (UI-first — THIS PHASE).** "Mini Apps" sidebar tab,
card grid, click to open. One **hardcoded** app (Twitter client) rendered in a
sandboxed iframe from a self-contained HTML file, reading static data through a
minimal bridge. Proves the *experience* and the *rendering path*. No agent,
no storage, no real Composio yet.
2. **The scoped bridge.** Real interactive buttons → real Composio actions,
enforced against a per-app manifest scope; auth prompt on demand.
3. **Generation.** Copilot/Code-Mode generates the UI+schema+agent triple from a
chat description. Rides the existing chat mode selector + `code-with-agents`.
4. **Living apps + breakage recovery.** Conversational edits; error surfacing.
5. **Scale & polish.** Context-overload windowing (e.g. 100 tweets); app
notifications (reuse `notify-user`).
6. **(V2)** Drag an app into the main workspace (macOS-style).
## 2b. Phase 2.5 — On-disk apps + `app://miniapp` serving (CURRENT)
Move built-in apps out of source into `~/.rowboat/apps/<id>/`, served as static
assets, with an optional background agent producing `data.json`. Sets up the
copilot builder path (it will write the same folder layout). Team-agreed.
### On-disk layout (one folder per app)
```
~/.rowboat/apps/<id>/
manifest.json # id, title, description, source, scope[], active, lastRun,
# entry (default "dist/index.html"), agent (optional bg-task slug)
dist/ # static assets served via app://miniapp/<id>/...
index.html # the app (self-contained; bridge shim inlined)
data.json # latest agent output (read by the host, pushed to the app)
```
### Serving — `app://miniapp/<id>/<path>` (option A)
- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host
`miniapp`: map `app://miniapp/<id>/<path>``~/.rowboat/apps/<id>/dist/<path>`
(path-traversal guarded; default `index.html`). `app://` is already registered
privileged (standard/secure/fetch/cors) so apps get a **real origin**
remote CDNs/images and `fetch` work, unlike the opaque `srcdoc` origin.
- The iframe loads via `src="app://miniapp/<id>/index.html"` (not `srcDoc`).
Sandbox becomes `allow-scripts allow-same-origin allow-popups allow-forms
allow-modals allow-downloads` — same-origin is its OWN `app://miniapp` origin,
still isolated from the renderer (different host).
### Data path
- Built-in/static `data` is seeded to `data.json`. A future background agent
(reuse bg-tasks engine, linked via manifest `agent`) overwrites it on schedule.
- App keeps using `rowboat.onData`; the **host** now sources that data by reading
`~/.rowboat/apps/<id>/data.json` (via IPC) and posting it on `ready`. Composio
still flows through the bridge RPC. (GitHub app needs no `data.json` — it pulls
live via Composio.)
### Steps
1. **Shared schema**`packages/shared/src/mini-app.ts`: `MiniAppManifest` zod +
type. Export it. New IPC channels in `shared/src/ipc.ts`:
- `mini-apps:seed` (req: `{apps:[{manifest, html, data?}]}`) — idempotent.
- `mini-apps:list` (res: `{manifests: MiniAppManifest[]}`).
- `mini-apps:get-data` (req `{id}`, res `{data: unknown|null}`).
2. **Main**`apps/main/src/mini-apps-handler.ts`: `seedApps` (write
manifest/dist/data to `~/.rowboat/apps/<id>/` only if absent), `listApps`
(read manifests), `getAppData` (read data.json). Register handlers in
`ipc.ts`. Extend `registerAppProtocol` for the `miniapp` host.
3. **Renderer** — keep `MiniApp` defs + `buildMiniAppHtml`; `registry.ts` adds
`toSeed(app)` (→ `{manifest, html, data}`). `MiniAppsView`: on mount → `seed`
`list` → render cards from manifests. `MiniAppFrame`: take the manifest,
load `src=app://miniapp/<id>/<entry>`, on `ready` fetch `mini-apps:get-data`
and post it; RPC scope from `manifest.scope`.
4. **Verify**: GitHub app end-to-end from disk (`~/.rowboat/apps/github-radar/`),
then the others; confirm connect + live PRs still work.
### Out of scope here (next: copilot builder, tomorrow)
Copilot writing a new app folder + an associated background agent; the agent
browsing via embedded browser for social feeds; copilot verifying Composio wiring
by actually calling tools before finalizing.
## 2c. Remaining work (after on-disk move)
Done so far: surface + runtime (Phase 1), real scoped Composio bridge (Phase 2),
apps on disk served via `app://miniapp` (Phase 2.5).
**Next — Copilot builder (demo target).**
- Copilot creates an app folder in `~/.rowboat/apps/<id>/` via the `mini-apps:seed`
install primitive (writes `manifest.json` + `dist/index.html`).
- Copilot must **verify wiring by actually calling Composio tools** and inspecting
the returned data before finalizing — never speculate the shape.
- Give generated apps the bridge contract — prefer **serving a canonical shim**
from the protocol (e.g. `app://miniapp/__bridge__.js`) over inlining.
**Background-agent data pipeline (deterministic).**
- Reuse the existing bg-tasks engine; link via `manifest.agent`.
- The agent does NOT write files. It **returns structured data validated against
the app's data schema**; the bg-task **runner (code) atomically writes the
app's `data.json`** (temp→rename, keep last-good on failure). Path / atomicity
/ validation / fallback all live in code — only the *content* is LLM-driven.
- Social feeds (Twitter/LinkedIn/Reddit): the agent browses via the embedded
browser, curates, returns data → runner writes `data.json`.
**Later (roadmap).**
- Living apps + breakage recovery (edit by chat; surface errors → fix-with-copilot).
- Scale/polish: context-overload windowing; app notifications; design-consistency
/ component templates (team-deferred).
- V2: drag an app into the main workspace.
## 2d. Mini App builder skill — spec
A Copilot skill (`build-mini-app`, in `packages/core/src/application/assistant/
skills/`) that turns a chat request into an installed app under `~/.rowboat/
apps/<id>/`. Copilot orchestrates; the actual code-writing is delegated by the
chat's active engine (the chip), but the on-disk artifact is identical either way.
**Trigger + intent gate** (like live-note/background-task signal tiers):
- **Strong (build directly):** "make/build/create an app · mini app · dashboard
for …", "turn this into an app".
- **Medium (CONFIRM FIRST):** requests that could be a one-off answer or a
recurring app — e.g. "show me my open PRs", "track competitor launches". Ask:
"Want this as a Mini App you can reopen, or just a one-time answer?" Build only
on yes. (Building installs a folder + maybe a bg agent + an OAuth prompt — too
heavy to trigger on a casual question.)
- **Anti (do NOT build):** clearly one-off lookups/questions → just answer.
**Flow:**
1. **Scope the app** — derive `id` (slug), `title`, `description`, `source`, the
Composio `scope[]`, and whether it's **agent-backed** (scheduled data) or
**live** (calls Composio on use).
2. **Verify wiring BEFORE building** (mandatory, engine-agnostic) — ensure the
toolkits are connected (prompt OAuth if not), then actually call the needed
tools (`composio-search-tools``composio-execute-tool`), inspect the REAL
returned data, and derive the **data schema from actual responses** — never
guess the shape.
3. **Pick the writer (branch):**
- **Code Mode active** → create the folder + a manifest skeleton, then
`code_agent_run` with `cwd = ~/.rowboat/apps/<id>/` to author
`dist/index.html` against the verified schema + bridge contract; it can
iterate/test on-device.
- **No Code Mode** → Copilot writes `dist/index.html` itself (from the app
template + bridge shim) and installs via `mini-apps:seed`.
4. **Bridge contract** — generated app references the canonical shim
(`app://miniapp/__bridge__.js`) and uses `window.rowboat`
(`getData/onData`, `isConnected/connect`, `searchTools/callAction`).
5. **Data pipeline (if agent-backed)** — create a background task (existing
bg-tasks engine), set `manifest.agent` to its slug. The agent **returns
schema-validated data**; the **runner writes `data.json` deterministically**
(temp→rename, last-good on failure). App reads it via `onData`.
6. **Finalize** — write `manifest.json` (incl. `scope`, `agent?`), ensure
`dist/index.html`, install → app appears in the gallery (`mini-apps:list`).
Confirm end-to-end (open it; data loads).
**Infra this needs (repo side):**
- Serve the canonical bridge shim from the protocol: `app://miniapp/__bridge__.js`
(move the shim out of per-app inlining into served infra).
- bg-tasks runner: when a task is app-linked, validate its structured output
against the app schema and write that app's `data.json` (deterministic write
mode) instead of `index.md`.
- A `build-mini-app` skill registered in the skills catalog.
## 3. Phase 1 — detailed implementation
UI-first. Everything hand-coded; no `~/.rowboat` storage, no IPC, no agent yet.
We *do* mirror the eventual shapes so later phases slot in.
### 3.1 New files (renderer)
| File | Purpose |
|------|---------|
| `apps/renderer/src/mini-apps/types.ts` | `MiniApp` type (id, name, description, icon, accent, scope, html, data). |
| `apps/renderer/src/mini-apps/registry.ts` | Hardcoded list of `MiniApp`s for Phase 1. |
| `apps/renderer/src/mini-apps/apps/twitter-client.ts` | The sample app: self-contained HTML (React+Tailwind+Babel CDN) + static `data`. |
| `apps/renderer/src/components/mini-apps-view.tsx` | Card grid; internal `selectedAppId` state; renders grid or open app. |
| `apps/renderer/src/components/mini-app-frame.tsx` | Sandboxed iframe (`srcdoc`) + `window.rowboat` postMessage bridge (data wired; actions stubbed → toast/log). |
The bridge message protocol (host ↔ iframe), defined once and shared:
- iframe → host: `{ type: 'rowboat:mini-app:ready' }`,
`{ type: 'rowboat:mini-app:action', id, scope, action, args }`,
`{ type: 'rowboat:mini-app:setState', patch }`
- host → iframe: `{ type: 'rowboat:mini-app:data', data }`,
`{ type: 'rowboat:mini-app:state', state }`,
`{ type: 'rowboat:mini-app:action-result', id, ok, result?, error? }`
### 3.2 Wiring into `App.tsx` (mirror the `bg-tasks` view exactly)
Add a first-class `apps` view. Edit sites (all mirror an existing view):
1. **Tab path const** (~L198): add `const APPS_TAB_PATH = '__rowboat_mini_apps__'`.
2. **Tab predicate** (~L374): `const isAppsTabPath = (path) => path === APPS_TAB_PATH`.
3. **ViewState union** (~L636): add `| { type: 'apps' }`.
4. **`parseDeepLink`** (~L713): add `case 'apps': return { type: 'apps' }`.
5. **State flag** (~L825): `const [isAppsOpen, setIsAppsOpen] = useState(false)`.
6. **Tab title** (~L1253): `if (isAppsTabPath(tab.path)) return 'Mini Apps'`.
7. **Tab activation → flag** (~L3269, ~L3443): set `isAppsOpen` from tab path.
8. **Closing-tab guard** (~L3376): add `&& !isAppsTabPath(closingTab.path)`.
9. **`currentViewState`** (~L3770): `if (isAppsOpen) return { type: 'apps' }`
(place alongside bg-tasks; add to deps array).
10. **`ensureAppsFileTab`** (~L3853): mirror `ensureBgTasksFileTab`.
11. **`openAppsView`** (~L3953): mirror `openBgTasksView`; reset all other flags,
`setIsAppsOpen(true)`, `ensureAppsFileTab()`.
12. **`applyViewState` switch** (~L4195): add `case 'apps':` mirroring `bg-tasks`.
13. **Add `setIsAppsOpen(false)`** to every *other* view's reset block (the
shared multi-`set...(false)` lines) so opening another view clears apps and
closing it doesn't fall back to apps. Use targeted edits per block.
14. **Derived booleans** that OR all view flags (isFullScreenChat,
rightPaneAvailable, inFileView, rightPaneContext, viewOpen, keyboard guards):
add `|| isAppsOpen` / `&& !isAppsOpen` consistently (search `isBgTasksOpen`).
15. **Tab-path mapping** (~L4668): add `: isAppsOpen ? APPS_TAB_PATH`.
16. **Render branch** (~L5894): add `) : isAppsOpen ? (<MiniAppsView ... />`.
### 3.3 Sidebar entry (`sidebar-content.tsx`)
- Add `onOpenApps?: () => void` prop (~L163) and destructure (~L412).
- Add a `SidebarMenuButton` with `isActive={activeNav === 'apps'}` and a
`LayoutGrid` (lucide) icon, label "Mini Apps", in the lower nav group near
"Background agents" (~L830).
- Thread `activeNav === 'apps'` from the parent (App.tsx passes `activeNav` based
on `isAppsOpen`, mirroring how `'agents'` is derived ~L5651).
- Wire `onOpenApps={openAppsView}` at the `SidebarContent`/home call sites
(~L5657, ~L5832).
### 3.4 The sample app (twitter-client)
Self-contained HTML string. React + ReactDOM + Babel-standalone + Tailwind, all
via CDN. Renders three sections — **To read / To repost / To respond** (reply
pre-drafted) — from data delivered via `rowboat.onData`. Buttons call
`rowboat.callAction('twitter', ...)`; in Phase 1 the host just toasts/logs and
returns a fake ok. Static `data` lives next to the HTML.
> Note: CDN scripts require network + `allow-scripts` in the sandbox. The frame
> uses `sandbox="allow-scripts allow-popups allow-forms allow-modals"` — **no**
> `allow-same-origin` (keeps the app from reaching host cookies/storage; the
> bridge is the only channel). Verify CDN loads under this sandbox during testing;
> if blocked, fall back to bundling the libs locally as a renderer asset.
### 3.5 Verify
- `cd apps/x && npm run deps && npm run lint` compiles clean.
- `npm run dev`: "Mini Apps" appears in the sidebar; opens the card grid; clicking
the Twitter card renders the app full-screen in the pane; sections populate from
data; clicking a button shows the stubbed action result; tabs/back/forward and
switching to other views behave like every other view.
## 4. Out of scope for Phase 1 (later phases)
`~/.rowboat/apps/<slug>/` storage + IPC; the agent backend (reuse bg-tasks
engine); real Composio execution through the bridge; per-app scope enforcement &
auth prompting; generation via Copilot/Code-Mode; persistent state store;
conversational editing & error recovery; the V2 workspace drag.

253
apps/x/VIDEO_MODE.md Normal file
View file

@ -0,0 +1,253 @@
# Calls (Video Mode) — Deep Dive
Calls let the user talk to the assistant hands-free while it *sees* them
(webcam) and their screen (screen share). There is ONE call engine —
continuous listening, auto-submitted utterances, forced read-aloud TTS, frame
capture — entered through four presets that differ only in starting devices.
This doc covers the product flow, the technical pipeline, and the LLM prompt
surface with exact pointers.
## Product flow
The composer has a **call split-button** (`chat-input-with-mentions.tsx`).
The main click is the "work together" default — preset `share`: screen
sharing ON, camera OFF, floating pill, so the user keeps working while the
assistant watches along (the button tooltip discloses the screen share). The
chevron menu holds the deviations. While a call is live the button turns red
and ends it.
| Preset | Starting devices | First surface |
|--------|------------------|---------------|
| `share` — main click | screen on, camera off | floating pill |
| `voice` — "Voice call" | camera off, screen off | floating mascot pill |
| `video` — "Video call" | camera on | full-screen call |
| `practice` — "Practice session" | camera on, + coaching persona | full-screen call |
**One surface rule** (`callSurface` in `App.tsx`): full screen and screen
sharing are mutually exclusive in both directions — a full-screen call covers
the screen, so sharing it would show the call itself.
- sharing → floating popout, always (pill = working)
- not sharing → full screen unless `callMinimized` (full screen = facing
each other)
- expanding the pill auto-STOPS any share; minimizing the full-screen call
auto-STARTS one (the pill exists to work together) — presenting from full
screen likewise collapses to the pill
- the camera toggle never changes the surface: turning it on from the pill
puts your video IN the pill; expanding is its own explicit action
**Screen-share consent** is three-layered: a toast the moment any share
starts ("Your screen is being shared… [Stop sharing]"), a persistent
"Sharing screen" badge on the pill, and macOS's purple recording indicator.
If the auto-share fails (Screen Recording permission not granted) the call
starts anyway as a voice call, with a toast linking to System Settings.
Practice/coaching is always an explicit choice — expanding to full screen
never turns the coach on.
In-call controls (identical bar on both surfaces): mic mute, camera toggle
(silhouette avatar while off, no webcam frames captured), screen share
toggle, mascot ⇄ "R" letter avatar, end call. **Mute is a full input
pause**, not just audio — mic audio stops reaching Deepgram
(`useVoiceMode.setPaused`, OR'd with the automatic thinking/speaking pause)
AND camera/screen frame capture stops (`useVideoMode.setCapturePaused`;
`collectFrames()` returns nothing while muted, so typed messages carry no
frames either), letting the user talk to someone in the room without the
assistant listening in. Devices stay acquired for instant unmute (camera
light and macOS share indicator stay on — the pill's share badge switches to
"Sharing paused"), the status chip shows "Muted" instead of "Listening",
and assistant output is unaffected (in-flight speech keeps playing; Stop
handles that). Mute resets to off at call start/end. While the assistant is thinking or speaking, a
red **Stop** button appears on the mascot tile — it silences TTS instantly,
skips queued voice segments, and aborts the run if it's still generating
(stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the
assistant's spoken line run along the bottom. Typing in the composer still
works mid-call; frames ride along with typed messages too.
Outside calls the composer keeps exactly one voice affordance: the **mic
button** (push-to-talk dictation, untouched). Spoken responses exist only
inside calls (forced full read-aloud, off on hang-up). The old video
dropdown, talking-head toggle, read-aloud headphones toggle, and summary/full
TTS dropdown are all retired — a per-message "read aloud" action on assistant
messages is the planned replacement for text-in/voice-out.
The call button is disabled unless both voice input (Deepgram) and voice
output (TTS) are configured. `call_started` (with `preset`) is captured in
PostHog — the adoption metric for this feature.
**Popout mechanics**: a small always-on-top frameless window (camera tile
when on + mascot tile, live caption, control bar) floating over every app —
including Rowboat. Control-bar actions round-trip `video:popoutAction`
main → `video:popout-action` → app window, which owns the mic/camera/capture;
`expand` also refocuses the app window (handled in main).
## Frame pipeline
`apps/renderer/src/hooks/useVideoMode.ts` runs one capture pipe per source
(stream → offscreen `<video>` → canvas JPEG → ring buffer):
- Cadence: 1 fps (`CAPTURE_INTERVAL_MS`, line 20); ring buffer ~2 min.
- Webcam: 512px wide, JPEG q0.65, max **12 frames/message** (lines 21, 31).
- Screen: 1280px wide (text legibility), JPEG q0.7, max **4 frames/message**
(lines 24, 32).
- `collectFrames()` drains frames buffered since the last send, evenly
sampled down to the caps, always keeping the newest; grabs one final frame
at the moment of send. Falls back to the single latest frame for
rapid-fire messages.
`App.tsx` `handlePromptSubmit` attaches the drained frames (whenever a call
is live) to the outgoing message as `UserImagePart`s and sets
`composition.videoMode` when the camera or screen is active, plus
`composition.coachMode` during a practice session. Frames also become
`isVideoFrame` display attachments (filmstrip in the transcript —
`chat-message-attachments.tsx`; history hydration in
`lib/run-to-conversation.ts`).
## Message schema & model encoding
- `packages/shared/src/message.ts:51``UserImagePart`: inline base64
(`data`, `mediaType`), `source: 'camera' | 'screen'`, `capturedAt`. Unlike
file attachments (path references read via the `LLMParse` tool), image
parts go to the model as real multimodal image parts.
- `packages/core/src/agents/runtime.ts` `convertFromMessages` (~line 1013):
emits a context line (frame counts + time span), then labeled groups —
a `"Webcam frames (oldest to newest):"` text part before camera images and
a `"Screen-share frames (oldest to newest):"` text part before screen
images — so the model never confuses the user with their screen.
- Frames stay inline in history (no pruning) deliberately: pruning would
bust provider prefix caching every turn and cost more than it saves.
- The auto-permission classifier stringifies + truncates content to ~3KB per
message, so inline base64 can't blow up its prompt.
## Hands-free voice loop
`apps/renderer/src/hooks/useVoiceMode.ts`:
- `startContinuous(onUtterance)` (line 404): push-to-talk params but with
`endpointing=1800` (line 25) so thinking pauses don't cut the user off,
plus `utterance_end_ms=2000` (line 38) as a second end-of-speech signal.
**Gotcha:** Deepgram's `speech_final` usually arrives on a result with an
EMPTY transcript — empty finals must reach the endpoint check or
utterances never complete (see the NOTE in `ws.onmessage`).
- `setPaused(true)` (line 414) while the assistant thinks/speaks: drops mic
audio (so TTS is never transcribed back), discards half-heard buffer,
sends Deepgram KeepAlives every 5s. `App.tsx` drives this from
`activeIsProcessing || tts.state !== 'idle'`.
- Mid-call socket drops reconnect after 1s; the offline audio backlog is
capped (~30s).
Call lifecycle lives in `App.tsx` `startCall(preset)` / `endCall()`:
entering a call saves/forces TTS settings, cancels any push-to-talk
recording, and starts the continuous loop; ending restores everything.
Push-to-talk is disabled while a call owns the mic.
## Popout window
- The popout window keeps the Dock icon alive: it uses
`setVisibleOnAllWorkspaces(true)` WITHOUT `visibleOnFullScreen` — that flag
turns the app into a macOS "agent" app and hides its Dock icon while the
window exists (looks like Rowboat vanished). Trade-off: the popout doesn't
hover over other apps' fullscreen Spaces.
- Shown iff the derived `callSurface === 'popout'` (effect in `App.tsx`).
Renderer asks `video:setPopout {show}`; main creates a frameless,
`alwaysOnTop` ('floating'), all-workspaces BrowserWindow at the top-right
of the primary display, loading the renderer bundle with `#video-popout`
(`apps/renderer/src/main.tsx` branches on the hash →
`components/video-popout.tsx`).
- Call state streams over the `video:popout-state` push channel; main caches
the last payload and replays it on popout load. Shown with
`showInactive()` so it never steals focus.
- The popout captures its **own** camera preview (MediaStreams can't cross
windows) and synthesizes the mascot mouth level (no audio in that window).
- `video:popoutAction` relays control-bar actions to the app window, matched
only by real app-window URLs — `getAllWindows()` also contains hidden
utility windows (PDF export) that must not be shown or messaged.
## Permissions
- Camera: `voice:ensureCameraAccess` settles the macOS TCC prompt before
`getUserMedia` (same pattern as the mic). `NSCameraUsageDescription` is in
`forge.config.cjs` `extendInfo`.
- Screen: `getDisplayMedia` is auto-approved with the primary screen by
`setDisplayMediaRequestHandler` in `main.ts` (no picker);
`meeting:checkScreenPermission` registers the app in macOS Screen
Recording settings on first use.
## LLM prompts catalog
| Prompt | Where |
|--------|-------|
| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/agents/runtime.ts:386` (`composeSystemInstructions`, gated on `videoMode`) |
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `composeSystemInstructions`, gated on `coachMode` (directly after the video section) |
| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`application/assistant/skills/app-navigation/skill.ts`) |
| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/agents/runtime.ts` (`convertFromMessages`) |
| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are
reused untouched — calls set `voiceInput` per utterance and force
`voiceOutput: 'full'`.
## Driving the app on a call
The assistant can drive the Rowboat UI itself via the extended
`app-navigation` builtin ("app driver"): `open-view` (any main view),
`read-view` (returns the emails / background agents / chat-history data the
view renders — and the renderer simultaneously navigates there so the user
watches it happen), and `open-item` (a specific email thread, note,
background agent, or past chat, deep-linked on screen). Data comes from the
same core functions the UI's IPC handlers use (`listImportantThreads` /
`searchThreads`, background-task `listTasks`, the sessions container) — no
OCR of screen frames. The renderer applies results via
`applyAppNavigation` in App.tsx, fed from BOTH event paths: the legacy
`runs:events` ref-poll AND a watcher over the session-chat conversation (the
turn runtime does not emit legacy run events — miss this and navigation
silently no-ops while the tool reports success). Session switches seed the
watcher so replaying history never navigates. During a call, visible
navigations also collapse the full-screen call to the pill and focus the app
window (`app:focusMainWindow`) so the user actually sees the screen change.
Card labels live in `lib/chat-conversation.ts`. The call prompt and the
`app-navigation` skill teach the show-while-telling pattern: read-view →
speak the highlights → open-item when the user picks one.
## Latency
Voice-to-voice latency (user stops talking → assistant audio) is engineered
at four points; the `call_turn_latency` PostHog event measures the real
distribution (utterance → submit → first speak → audio playing):
- **Smart endpointing** (`useVoiceMode.ts`): Deepgram endpoints at 600ms and
the client decides — a transcript ending in terminal punctuation fires
immediately (~600ms after last word); a mid-thought trail holds another
1.2s (resumed speech cancels the hold). Complete sentences turn around
~1.2s faster than the old fixed 1800ms endpoint.
- **Streaming TTS** (`voice:synthesizeStreamStart``voice:tts-chunk`
MediaSource playback in `useVoiceTTS.ts`): the first segment of an idle
queue plays from the first MP3 chunk instead of after the full body
(ElevenLabs `/stream`, flash model). Follow-up segments keep the gapless
full-body prefetch path. Falls back to non-streaming on any failure.
- **Early clause speech** (`turn-view.ts` `applyOverlay`): a still-open
`<voice>` block ≥60 chars emits its last complete clause immediately, so
speech starts while the rest of the sentence generates.
- **Acknowledgment cue** (`lib/call-sounds.ts`): a soft blip the instant an
utterance is accepted — perceived latency matters as much as measured.
## Cost notes
Webcam frames ≈ 250350 tokens each (≤12/message ≈ 34k); screen frames ≈
1.52k tokens each (≤4/message ≈ 68k). History keeps frames inline, so long
sessions grow but stay prefix-cached. First lever if cost bites: drop to one
screen frame per message unless the screen changed.
## Known limitations
- Turn-taking is strict — no barge-in (would need echo cancellation against
TTS output).
- Frame sampling, not video: motion between frames is invisible (the prompt
tells the model not to claim otherwise).
- Vocal-delivery feedback is limited: Deepgram reduces speech to text, so
"energy" coaching leans on visual cues.
- Screen share always captures the primary display (no window/display
picker yet).
- The full-screen call covers the chat; there's no in-call transcript drawer.
- The "attach camera frames to typed chat without a call" combination (the
old video+chat mode) was cut in the call-model simplification; if analytics
show demand, it should return as an attachment chip, not a mode.

View file

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

View file

@ -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',
@ -16,6 +199,7 @@ module.exports = {
],
extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
NSCameraUsageDescription: 'Rowboat uses your camera in video chat mode so the assistant can see you and give feedback (e.g. pitch practice).',
},
osxSign: {
batchCodesignCalls: true,
@ -29,17 +213,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: [
{
@ -86,6 +276,22 @@ module.exports = {
}
}
},
// 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"],
@ -178,7 +384,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/');
},
}
};
};

View file

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

View file

@ -13,14 +13,16 @@
"make": "electron-forge make"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
"@agentclientprotocol/codex-acp": "^0.0.44",
"@agentclientprotocol/claude-agent-acp": "^0.55.0",
"@agentclientprotocol/codex-acp": "^1.1.0",
"@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",
@ -29,6 +31,7 @@
},
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-base": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-rpm": "^7.11.1",

View file

@ -1,6 +1,6 @@
import { BrowserWindow } from 'electron';
import { ipc } from '@x/shared';
import { browserViewManager, type BrowserState } from './view.js';
import { browserViewManager, type BrowserState, type HttpAuthRequest } from './view.js';
type IPCChannels = ipc.IPCChannels;
@ -20,6 +20,7 @@ type BrowserHandlers = {
'browser:forward': InvokeHandler<'browser:forward'>;
'browser:reload': InvokeHandler<'browser:reload'>;
'browser:getState': InvokeHandler<'browser:getState'>;
'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>;
};
/**
@ -62,6 +63,9 @@ export const browserIpcHandlers: BrowserHandlers = {
'browser:getState': async () => {
return browserViewManager.getState();
},
'browser:httpAuthResponse': async (_event, args) => {
return browserViewManager.respondToHttpAuth(args);
},
};
/**
@ -70,12 +74,26 @@ export const browserIpcHandlers: BrowserHandlers = {
* window is created so the manager has a window to attach to.
*/
export function setupBrowserEventForwarding(): void {
browserViewManager.on('state-updated', (state: BrowserState) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('browser:didUpdateState', state);
}
// Only send to app windows, never to OAuth/SSO popup windows created by
// page window.open() — those render untrusted web content, and browsing
// state / auth-challenge metadata must not cross into them.
const broadcast = (channel: string, payload: unknown) => {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed() || !win.webContents) continue;
if (browserViewManager.isPopupWindow(win)) continue;
win.webContents.send(channel, payload);
}
};
browserViewManager.on('state-updated', (state: BrowserState) => {
broadcast('browser:didUpdateState', state);
});
browserViewManager.on('http-auth-request', (request: HttpAuthRequest) => {
broadcast('browser:httpAuthRequest', request);
});
browserViewManager.on('http-auth-resolved', (requestId: string) => {
broadcast('browser:httpAuthResolved', { requestId });
});
}

View file

@ -1,11 +1,12 @@
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
import { BrowserWindow, WebContentsView, session, shell, type Session, type WebContents } from 'electron';
import type {
BrowserPageElement,
BrowserPageSnapshot,
BrowserState,
BrowserTabState,
HttpAuthRequest,
} from '@x/shared/dist/browser-control.js';
import { normalizeNavigationTarget } from './navigation.js';
import {
@ -20,7 +21,7 @@ import {
type RawBrowserPageSnapshot,
} from './page-scripts.js';
export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
export type { BrowserPageSnapshot, BrowserState, BrowserTabState, HttpAuthRequest };
/**
* Embedded browser pane implementation.
@ -36,13 +37,33 @@ export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
export const BROWSER_PARTITION = 'persist:rowboat-browser';
// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
// that sniff the UA looking for "real browser" shapes.
const SPOOF_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
// Spoof a real Chrome UA so OAuth servers don't reject the embedded browser.
// The Chrome major version is derived from the running Chromium at startup:
// pinning a fixed version goes stale as Electron upgrades, and Chromium keeps
// emitting Sec-CH-UA client hints with the *real* version — a UA/client-hint
// version mismatch is a classic bot-detection signal (Google sign-in,
// Cloudflare). Minor version is frozen at 0.0.0, exactly like real Chrome's
// reduced UA. The platform token matches the actual OS for the same reason.
function getChromeMajorVersion(): number {
const major = Number.parseInt(process.versions.chrome ?? '', 10);
return Number.isFinite(major) && major > 0 ? major : 130;
}
function buildChromeUserAgent(): string {
const platformToken =
process.platform === 'darwin'
? 'Macintosh; Intel Mac OS X 10_15_7'
: process.platform === 'win32'
? 'Windows NT 10.0; Win64; x64'
: 'X11; Linux x86_64';
return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${getChromeMajorVersion()}.0.0.0 Safari/537.36`;
}
const SPOOF_UA = buildChromeUserAgent();
const HOME_URL = 'https://www.google.com';
const NAVIGATION_TIMEOUT_MS = 10000;
const HTTP_AUTH_TIMEOUT_MS = 120000;
const POST_ACTION_IDLE_MS = 400;
const POST_ACTION_MAX_ELEMENTS = 25;
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
@ -68,6 +89,13 @@ type CachedSnapshot = {
elements: Array<{ index: number; selector: string }>;
};
type PendingHttpAuth = {
callback: (username?: string, password?: string) => void;
timer: NodeJS.Timeout;
// The webContents that raised the challenge, so its teardown can cancel it.
webContents: WebContents;
};
const EMPTY_STATE: BrowserState = {
activeTabId: null,
tabs: [],
@ -109,6 +137,12 @@ export class BrowserViewManager extends EventEmitter {
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
private snapshotCache = new Map<string, CachedSnapshot>();
private pendingHttpAuth = new Map<string, PendingHttpAuth>();
// Child windows created by page window.open() (OAuth/SSO popups). Tracked so
// they can be closed when the host window goes away — otherwise an orphaned
// popup keeps BrowserWindow.getAllWindows() non-empty and, on macOS, blocks
// the app from reopening via the Dock (see main.ts 'activate' handler).
private popupWindows = new Set<BrowserWindow>();
private cleanupWindowListeners: (() => void) | null = null;
attach(window: BrowserWindow): void {
@ -137,6 +171,7 @@ export class BrowserViewManager extends EventEmitter {
if (this.window !== window) return;
const tabs = [...this.tabs.values()];
const popups = [...this.popupWindows];
this.cleanupWindowListeners = null;
this.window = null;
this.browserSession = null;
@ -150,6 +185,14 @@ export class BrowserViewManager extends EventEmitter {
this.attachedTabId = null;
this.visible = false;
this.snapshotCache.clear();
for (const requestId of [...this.pendingHttpAuth.keys()]) {
this.finishHttpAuth(requestId);
}
// Close any OAuth/SSO popups so they don't outlive the app window.
for (const popup of popups) {
if (!popup.isDestroyed()) popup.close();
}
this.popupWindows.clear();
};
hostWebContents.on('did-start-loading', handleDidStartLoading);
@ -171,6 +214,36 @@ export class BrowserViewManager extends EventEmitter {
if (this.browserSession) return this.browserSession;
const browserSession = session.fromPartition(BROWSER_PARTITION);
browserSession.setUserAgent(SPOOF_UA);
// Electron's Sec-CH-UA client hints only carry the "Chromium" brand;
// real Chrome also sends "Google Chrome". Some sign-in flows (notably
// Google's) distinguish the two, so rewrite the brand list to match what
// Chrome sends. Both the low-entropy header (`sec-ch-ua`, major versions)
// and the high-entropy one (`sec-ch-ua-full-version-list`, requested via
// Accept-CH and carrying full versions) must be rewritten together — a
// header that claims "Google Chrome" alongside one that doesn't is a
// stronger bot signal than the original. Only headers Chromium already
// attached are rewritten — none are added. (navigator.userAgentData JS
// brands still report only Chromium; there is no reliable hook to spoof
// that under sandbox+contextIsolation, and header-based detection is the
// common case.)
const chromeMajor = getChromeMajorVersion();
const chromeFull = process.versions.chrome ?? `${chromeMajor}.0.0.0`;
const brandLists: Record<string, string> = {
'sec-ch-ua': `"Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}", "Not-A.Brand";v="99"`,
'sec-ch-ua-full-version-list': `"Chromium";v="${chromeFull}", "Google Chrome";v="${chromeFull}", "Not-A.Brand";v="99.0.0.0"`,
};
browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
const requestHeaders = details.requestHeaders;
for (const name of Object.keys(requestHeaders)) {
const replacement = brandLists[name.toLowerCase()];
if (replacement !== undefined) {
requestHeaders[name] = replacement;
}
}
callback({ requestHeaders });
});
this.browserSession = browserSession;
return browserSession;
}
@ -196,17 +269,34 @@ export class BrowserViewManager extends EventEmitter {
return /^https?:\/\//i.test(url) || url === 'about:blank';
}
/**
* webPreferences shared by browser tabs and OAuth popups. Kept in one place
* so the security-sensitive popup surface can never drift from tabs.
*/
private browserWebPreferences(): Electron.WebPreferences {
return {
session: this.getSession(),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
// Chromium's built-in PDFium viewer, so PDFs render inline instead
// of showing a blank page.
plugins: true,
// Remove the WebAuthn API from the embedded browser only. Electron ships
// the API but not Chrome's authenticator UI (Touch ID sheet, QR/phone
// hybrid), so passkey challenges hang forever on "Verifying it's you...".
// With the API absent, sites feature-detect it and fall back to
// password/other verification. Scoped here (not app-wide) so the app's
// own renderer keeps WebAuthn.
disableBlinkFeatures: 'WebAuth',
};
}
private createView(): WebContentsView {
const view = new WebContentsView({
webPreferences: {
session: this.getSession(),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
webPreferences: this.browserWebPreferences(),
});
view.webContents.setUserAgent(SPOOF_UA);
return view;
}
@ -266,16 +356,145 @@ export class BrowserViewManager extends EventEmitter {
});
wc.on('page-title-updated', this.emitState.bind(this));
wc.setWindowOpenHandler(({ url }) => {
if (this.isEmbeddedTabUrl(url)) {
void this.newTab(url);
} else {
void shell.openExternal(url);
this.wireWindowPolicy(wc);
}
/**
* Window-open, popup, and HTTP-auth wiring shared by tabs and popups.
*/
private wireWindowPolicy(wc: WebContents): void {
wc.setWindowOpenHandler((details) => this.handleWindowOpen(details));
wc.on('did-create-window', (child) => this.wirePopupWindow(child));
this.wireHttpAuth(wc);
}
/**
* Shared window.open / target=_blank policy for tabs and popups.
*
* An open that hands a handle back to the opener must become a real child
* window so window.opener / postMessage survive this is how OAuth/SSO
* popups (Google, Microsoft, Plaid, ...) return their result; denying them
* also makes sites report "popup blocked". Those are: a sized popup
* (disposition 'new-window'), a *named* window.open(url, 'name') (non-empty
* frameName), or a scripted blank window the opener will populate
* (about:blank). A nameless target=_blank link (foreground-tab, empty
* frameName) has no opener contract and opens as a tab, matching browser
* behavior. Non-web schemes go to the system handler.
*
* Residual gap: a nameless, featureless window.open(url) is indistinguishable
* from a _blank link (both foreground-tab + empty frameName) and opens as a
* tab, losing its opener rare for OAuth, which virtually always names or
* sizes its popup.
*/
private handleWindowOpen(details: Electron.HandlerDetails): Electron.WindowOpenHandlerResponse {
const { url, disposition, frameName } = details;
if (this.isEmbeddedTabUrl(url)) {
const needsOpener =
disposition === 'new-window' || frameName !== '' || url === 'about:blank';
if (needsOpener) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: this.browserWebPreferences(),
},
};
}
void this.newTab(url);
return { action: 'deny' };
}
void shell.openExternal(url);
return { action: 'deny' };
}
private wirePopupWindow(child: BrowserWindow): void {
this.popupWindows.add(child);
child.once('closed', () => this.popupWindows.delete(child));
this.wireWindowPolicy(child.webContents);
}
/** True if `win` is an OAuth/SSO popup created by page window.open(). */
isPopupWindow(win: BrowserWindow): boolean {
return this.popupWindows.has(win);
}
/**
* HTTP basic/proxy auth. Chromium's default is to cancel the challenge, so
* 401-protected sites and authenticating proxies dead-end. When the browser
* pane is on screen to answer, forward the challenge to it as a credential
* prompt (cancelled after a timeout if unanswered). When the pane is closed
* e.g. agent-driven navigation don't preventDefault, so Chromium cancels
* immediately and the 401 page is readable rather than hanging.
*/
private wireHttpAuth(wc: WebContents): void {
wc.on('login', (event, _details, authInfo, callback) => {
if (!this.visible || !this.window) return;
event.preventDefault();
const requestId = randomUUID();
const timer = setTimeout(() => {
this.finishHttpAuth(requestId);
}, HTTP_AUTH_TIMEOUT_MS);
this.pendingHttpAuth.set(requestId, { callback, timer, webContents: wc });
// If the challenging contents dies before an answer, resolve now so the
// native callback and timer don't leak (backstop for paths other than
// destroyTab, which cancels explicitly before removeAllListeners()).
wc.once('destroyed', () => this.finishHttpAuth(requestId));
const request: HttpAuthRequest = {
requestId,
host: authInfo.host,
isProxy: authInfo.isProxy,
...(authInfo.realm ? { realm: authInfo.realm } : {}),
};
this.emit('http-auth-request', request);
});
}
/**
* Resolve a pending auth challenge. `username === undefined` cancels it; an
* empty-string username is a valid submission (token-style Basic auth).
* Always notifies the renderer so a dialog it may still be showing (e.g.
* after a timeout or tab close) is pruned.
*/
private finishHttpAuth(requestId: string, username?: string, password?: string): boolean {
const pending = this.pendingHttpAuth.get(requestId);
if (!pending) return false;
this.pendingHttpAuth.delete(requestId);
clearTimeout(pending.timer);
try {
if (username == null) {
pending.callback();
} else {
pending.callback(username, password ?? '');
}
} catch {
// The challenged webContents may already be destroyed.
}
this.emit('http-auth-resolved', requestId);
return true;
}
private cancelHttpAuthForWebContents(wc: WebContents): void {
const ids: string[] = [];
for (const [requestId, pending] of this.pendingHttpAuth) {
if (pending.webContents === wc) ids.push(requestId);
}
for (const requestId of ids) {
this.finishHttpAuth(requestId);
}
}
respondToHttpAuth(input: {
requestId: string;
username?: string;
password?: string;
}): { ok: boolean } {
return { ok: this.finishHttpAuth(input.requestId, input.username, input.password) };
}
private snapshotTabState(tab: BrowserTab): BrowserTabState {
const wc = tab.view.webContents;
return {
@ -364,6 +583,10 @@ export class BrowserViewManager extends EventEmitter {
private destroyTab(tab: BrowserTab): void {
this.invalidateSnapshot(tab.id);
// Cancel any auth challenge this tab raised before we drop its listeners,
// so the native callback + timer don't leak and the renderer prunes its
// dialog (removeAllListeners() below would kill the 'destroyed' backstop).
this.cancelHttpAuthForWebContents(tab.view.webContents);
tab.view.webContents.removeAllListeners();
if (!tab.view.webContents.isDestroyed()) {
tab.view.webContents.close();

View file

@ -315,3 +315,50 @@ export async function listToolkits() {
totalItems: filtered.length,
};
}
/**
* Execute a Composio tool by slug on behalf of a Mini App. The toolkit must be
* connected (ACTIVE). Mirrors the agent's composio-execute-tool builtin.
*/
export async function executeTool(
toolkitSlug: string,
toolSlug: string,
args?: Record<string, unknown>,
): Promise<{ successful: boolean; data?: unknown; error?: string }> {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return { successful: false, error: `Toolkit "${toolkitSlug}" is not connected.` };
}
try {
const result = await composioClient.executeAction(toolSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: args ?? {},
});
return { successful: result.successful, data: result.data, error: result.error ?? undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Mini App tool execution failed for ${toolSlug}:`, message);
return { successful: false, error: `Failed to execute ${toolSlug}: ${message}` };
}
}
/**
* Search Composio tools within a toolkit so a Mini App can discover the right
* tool slug + input schema at runtime (how generated apps will wire actions).
*/
export async function searchToolsInToolkit(
toolkitSlug: string,
query: string,
): Promise<{ tools: Array<{ slug: string; name: string; description?: string }>; error?: string }> {
try {
const { items } = await composioClient.searchTools(query, [toolkitSlug]);
return {
tools: items.map((t) => ({ slug: t.slug, name: t.name, description: t.description })),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { tools: [], error: message };
}
}

View file

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

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

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,11 @@
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
import path from "node:path";
import {
setupIpcHandlers,
startRunsWatcher,
startRunsWatcher, startSessionsWatcher, markSessionsIndexReady,
startCodeRunFeedWatcher,
startChannelsWatcher,
startCodeSessionStatusWatcher,
startServicesWatcher,
startLiveNoteAgentWatcher,
startBackgroundTaskAgentWatcher,
@ -11,6 +14,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";
@ -23,25 +27,32 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initChannels } from "@x/core/dist/channels/service.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js";
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.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 container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
import type { ISessions } from "@x/core/dist/sessions/index.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
@ -54,7 +65,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 +225,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,
@ -253,20 +299,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 {
@ -274,6 +343,16 @@ function createWindow() {
}
}
// Renderer/child process deaths are otherwise silent in packaged builds (the
// window or an app iframe just goes blank). Log the reason so crash reports
// can be correlated with what Chromium thought happened.
app.on('render-process-gone', (_event, webContents, details) => {
console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`);
});
app.on('child-process-gone', (_event, details) => {
console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`);
});
app.whenReady().then(async () => {
// Register custom protocol before creating window.
// In production this serves the renderer SPA; in dev (and prod) it also
@ -291,18 +370,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();
@ -315,11 +389,29 @@ app.whenReady().then(async () => {
});
registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService());
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
setupIpcHandlers();
setupBrowserEventForwarding();
// Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
// the window and the long service-init chain below. The Apps view is
// reachable as soon as the window paints; starting the server last meant
// every app iframe hit connection-refused (blank app) for the first ~10s of
// each launch. Route registration and the token cipher are synchronous;
// the listen itself is fire-and-forget.
registerAppsHostApi();
// GitHub publish token at rest: encrypt via the OS keychain when available
// (core stays electron-free; the cipher is injected here).
setGithubTokenCipher({
isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
});
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});
createWindow();
// Start workspace watcher as a main-process service
@ -332,6 +424,48 @@ app.whenReady().then(async () => {
// start runs watcher
startRunsWatcher();
// One-time: port legacy runs/*.jsonl into the new turn/session runtime.
// Must run BEFORE the session index is built so migrated sessions are picked
// up by the startup scan. Fully defensive — never blocks boot.
try {
const migration = migrateRuns();
if (migration.scanned > 0) {
console.log(
`[runs-migration] migrated ${migration.migratedTurns} turn(s) across ` +
`${migration.migratedSessions} session(s) from ${migration.scanned} run(s) ` +
`(${migration.skipped} skipped, ${migration.failed.length} failed)`,
);
for (const failure of migration.failed) {
console.warn(`[runs-migration] left in place (failed): ${failure.file}${failure.error}`);
}
}
} catch (error) {
console.error('[runs-migration] pass failed:', error);
}
// New runtime: build the in-memory session index (startup scan), then
// forward the session bus to windows. The renderer window is already up and
// may have called sessions:list — that handler blocks on
// markSessionsIndexReady, which must fire even if the scan throws so the
// list never hangs.
try {
await container.resolve<ISessions>('sessions').initialize();
} finally {
markSessionsIndexReady();
}
startSessionsWatcher();
startCodeRunFeedWatcher();
// Mobile channels (WhatsApp/Telegram bridge): needs the session index, so
// start after initialize(). Failures must never block boot.
startChannelsWatcher();
initChannels().catch((error) => {
console.error('[Channels] Failed to start mobile channels:', error);
});
// start code-session status tracker (derives working/needs-you/idle + notifications)
startCodeSessionStatusWatcher();
// start services watcher
startServicesWatcher();
@ -347,6 +481,10 @@ app.whenReady().then(async () => {
// start bg-task scheduler (cron / window)
initBackgroundTaskScheduler();
// start disk-skills watcher: live-reload skills dropped into
// ~/.rowboat/skills or ~/.agents/skills without an app restart
startSkillsWatcher();
// register event consumers and start the shared event processor
// (consumes $WorkDir/events/pending/, routes events to all consumers
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
@ -392,14 +530,12 @@ app.whenReady().then(async () => {
// start calendar meeting notification service (fires 1-minute warnings)
initCalendarNotifications();
// start meeting prep scheduler (generates prep notes ~6h before a meeting)
void initMeetingPrep();
// start chrome extension sync server
initChromeSync();
// start local sites server for iframe dashboards and other mini apps
initLocalSites().catch((error) => {
console.error('[LocalSites] Failed to start:', error);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
@ -418,14 +554,17 @@ app.on("before-quit", () => {
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
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
}
shutdownLocalSites().catch((error) => {
console.error('[LocalSites] Failed to shut down cleanly:', error);
// Kill embedded terminal shells.
disposeAllTerminals();
shutdownAppsServer().catch((error) => {
console.error('[Apps] Failed to shut down cleanly:', error);
});
shutdownAnalytics().catch((error) => {
console.error('[Analytics] Failed to flush on quit:', error);

View file

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

View file

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

View file

@ -0,0 +1,126 @@
import { BrowserWindow } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// node-pty is a NATIVE module: it stays external to the esbuild bundle and is
// shipped alongside it in .package/node_modules (see bundle.mjs).
import * as pty from 'node-pty';
// One PTY per coding session, kept alive while the app runs so the terminal
// survives pane collapses and session switches. The renderer view re-attaches
// via `terminal:ensure`, which replays the recent backlog.
const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay
interface TerminalEntry {
proc: pty.IPty;
cwd: string;
backlog: string;
running: boolean;
}
const terminals = new Map<string, TerminalEntry>();
function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send(channel, payload);
}
}
}
// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable
// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once.
let helperFixed = false;
function ensureSpawnHelperExecutable(): void {
if (helperFixed || process.platform === 'win32') return;
helperFixed = true;
try {
const pkgDir = path.dirname(require.resolve('node-pty/package.json'));
const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
if (fs.existsSync(helper)) {
fs.chmodSync(helper, 0o755);
}
} catch {
// best effort — spawn() will surface a real error if this mattered
}
}
function defaultShell(): { file: string; args: string[] } {
if (process.platform === 'win32') {
return { file: 'powershell.exe', args: [] };
}
// Login shell so the user's PATH/aliases match their normal terminal.
return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] };
}
function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry {
ensureSpawnHelperExecutable();
const { file, args } = defaultShell();
const proc = pty.spawn(file, args, {
name: 'xterm-256color',
cwd,
cols,
rows,
env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record<string, string>,
});
const entry: TerminalEntry = { proc, cwd, backlog: '', running: true };
proc.onData((data) => {
entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT);
broadcast('terminal:data', { id, data });
});
proc.onExit(({ exitCode }) => {
entry.running = false;
broadcast('terminal:exit', { id, exitCode });
});
terminals.set(id, entry);
return entry;
}
// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or
// an exited shell gets a fresh PTY; otherwise the live one is reused and the
// caller repaints from the backlog.
export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } {
const existing = terminals.get(id);
if (existing && existing.running && existing.cwd === cwd) {
existing.proc.resize(cols, rows);
return { backlog: existing.backlog, running: true };
}
if (existing) {
disposeTerminal(id);
}
const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir();
const entry = spawnEntry(id, fallbackCwd, cols, rows);
return { backlog: entry.backlog, running: entry.running };
}
export function writeTerminal(id: string, data: string): void {
const entry = terminals.get(id);
if (entry?.running) entry.proc.write(data);
}
export function resizeTerminal(id: string, cols: number, rows: number): void {
const entry = terminals.get(id);
if (entry?.running) {
try {
entry.proc.resize(cols, rows);
} catch {
// resizing a dying pty throws — harmless
}
}
}
export function disposeTerminal(id: string): void {
const entry = terminals.get(id);
if (!entry) return;
terminals.delete(id);
try {
entry.proc.kill();
} catch {
// already gone
}
}
export function disposeAllTerminals(): void {
for (const id of [...terminals.keys()]) disposeTerminal(id);
}

View file

@ -6,10 +6,18 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"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 +46,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",
@ -74,6 +85,9 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
@ -82,9 +96,11 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "catalog:"
}
}

View file

@ -0,0 +1,62 @@
/**
* Regenerates the bundled product-tour narration clips.
*
* Parses the TOUR_STEPS texts straight out of product-tour.tsx (so the code
* stays the single source of truth), synthesizes each one via @x/core's
* synthesizeSpeech (Rowboat proxy when signed in, direct ElevenLabs otherwise,
* using the voice id configured there / in ~/.rowboat/config/elevenlabs.json),
* and writes MP3s to src/assets/tour/<step-id>.mp3.
*
* Run whenever a step's narration text or the tour voice changes:
* cd apps/x && npm run deps # script imports core's built output
* node apps/renderer/scripts/generate-tour-audio.mjs
*
* Pass step ids to regenerate only those clips (e.g. to re-roll one whose
* synthesis came out glitchy):
* node apps/renderer/scripts/generate-tour-audio.mjs welcome done
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const tourSource = path.join(here, '../src/components/product-tour.tsx')
const outDir = path.join(here, '../src/assets/tour')
const corePath = path.join(here, '../../../packages/core/dist/voice/voice.js')
const { synthesizeSpeech } = await import(corePath)
const src = await readFile(tourSource, 'utf8')
const start = src.indexOf('const TOUR_STEPS')
const end = src.indexOf('\n]', start)
if (start === -1 || end === -1) throw new Error('Could not locate TOUR_STEPS in product-tour.tsx')
const block = src.slice(start, end)
const steps = []
// voiceText, when present, is the spoken variant of the bubble text.
const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')(?:,\s*voiceText:\s*("[^"]*"|'[^']*'))?/g
for (let m; (m = re.exec(block)); ) {
// The captures are JS string literals from our own source; evaluate them
// to resolve the quoting.
steps.push({ id: m[1], text: new Function(`return ${m[3] ?? m[2]}`)() })
}
if (steps.length === 0) throw new Error('Parsed zero tour steps — regex out of sync with product-tour.tsx?')
console.log(`Parsed ${steps.length} tour steps`)
const only = process.argv.slice(2)
if (only.length > 0) {
const unknown = only.filter((id) => !steps.some((s) => s.id === id))
if (unknown.length > 0) throw new Error(`Unknown step ids: ${unknown.join(', ')}`)
steps.splice(0, steps.length, ...steps.filter((s) => only.includes(s.id)))
console.log(`Regenerating only: ${only.join(', ')}`)
}
await mkdir(outDir, { recursive: true })
for (const step of steps) {
process.stdout.write(`synthesizing ${step.id}... `)
const { audioBase64 } = await synthesizeSpeech(step.text)
const file = path.join(outDir, `${step.id}.mp3`)
await writeFile(file, Buffer.from(audioBase64, 'base64'))
console.log(`${(audioBase64.length * 0.75 / 1024).toFixed(0)} KB`)
}
console.log(`Done — ${steps.length} clips in ${path.relative(process.cwd(), outDir)}`)

View file

@ -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;
@ -186,6 +169,25 @@
color: var(--gm-placeholder);
}
.gmail-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.gmail-search-clear:hover {
background: var(--gm-icon-hover-bg);
color: var(--gm-text);
}
.gmail-icon-button {
display: inline-flex;
align-items: center;
@ -222,6 +224,46 @@
flex-direction: column;
}
/* Native list virtualization: offscreen rows skip layout and paint entirely.
Applied only to rows without a mounted ThreadDetail (those hold iframes and
composers, which must keep rendering while offscreen). */
.gmail-row-group-cv {
content-visibility: auto;
contain-intrinsic-size: auto 40px;
}
/* While the list is scrolling, rows ignore the pointer so hover restyles and
prefetch timers don't compete with frame rendering. */
.gmail-shell[data-scrolling] .gmail-row-shell {
pointer-events: none;
}
/* Archived/trashed rows slide out and collapse before removal. Removing the
class (failed action) snaps the row back. */
.gmail-row-group-leaving {
overflow: hidden;
pointer-events: none;
animation: gmail-row-leave 160ms ease-in forwards;
}
@keyframes gmail-row-leave {
0% {
opacity: 1;
transform: translateX(0);
max-height: 48px;
}
60% {
opacity: 0;
transform: translateX(32px);
max-height: 48px;
}
100% {
opacity: 0;
transform: translateX(32px);
max-height: 0;
}
}
.gmail-list-header {
position: sticky;
top: 0;
@ -288,6 +330,11 @@
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
/* The overlay may extend over the subject text; a backdrop matching the
row's current background (see --gm-row-actions-bg below) plus a soft left
fade keeps it readable instead of colliding. */
padding-left: 18px;
background: linear-gradient(to right, transparent, var(--gm-row-actions-bg, var(--gm-bg-row-hover)) 18px);
}
.gmail-row-shell:hover .gmail-row-actions {
@ -295,6 +342,19 @@
pointer-events: auto;
}
/* Backdrop color tracks the row state underneath the overlay. */
.gmail-row-shell:hover {
--gm-row-actions-bg: var(--gm-bg-row-hover);
}
.gmail-row-shell:hover:has(.gmail-row-selected) {
--gm-row-actions-bg: var(--gm-bg-row-selected-hover);
}
.gmail-row-shell:hover:has(.gmail-row-selected.gmail-row-focused) {
--gm-row-actions-bg: var(--gm-bg-row-selected);
}
.gmail-row-shell:hover .gmail-row-date {
visibility: hidden;
}
@ -319,10 +379,10 @@
}
.gmail-row-action-danger:hover {
color: #e8453c;
color: var(--destructive);
}
.gmail-row:hover {
.gmail-row-shell:hover .gmail-row {
background: var(--gm-bg-row-hover);
box-shadow: none;
}
@ -332,10 +392,24 @@
box-shadow: inset 2px 0 0 var(--gm-accent);
}
.gmail-row-selected:hover {
.gmail-row-shell:hover .gmail-row-selected {
background: var(--gm-bg-row-selected-hover);
}
/* The j/k keyboard cursor. Declared after the hover rules (same specificity)
so the focus ring survives hovering the focused row. */
.gmail-row-focused,
.gmail-row-shell:hover .gmail-row-focused {
background: var(--gm-bg-row-hover);
box-shadow: inset 0 0 0 1px var(--gm-accent);
}
.gmail-row-selected.gmail-row-focused,
.gmail-row-shell:hover .gmail-row-selected.gmail-row-focused {
background: var(--gm-bg-row-selected);
box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent);
}
.gmail-row-unread {
color: var(--gm-text);
}
@ -411,12 +485,50 @@
border-top: 1px solid var(--gm-border);
border-bottom: 1px solid var(--gm-border);
box-shadow: inset 2px 0 0 var(--gm-accent);
/* Replays whenever the detail is shown hidden details are display: none,
so un-hiding restarts the animation. */
animation: gmail-detail-open 140ms ease-out;
}
.gmail-detail-hidden {
display: none;
}
@keyframes gmail-detail-open {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
/* The inline reply/forward composer pops in from below the thread. */
.gmail-compose-inline {
animation: gmail-compose-open 140ms ease-out;
}
@keyframes gmail-compose-open {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gmail-detail-inline,
.gmail-compose-inline,
.gmail-row-group-leaving {
animation: none;
}
}
.gmail-detail-toolbar {
display: flex;
align-items: center;
@ -707,411 +819,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 +1146,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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -22,7 +22,7 @@ import {
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
import { getToolActionsSummary, getToolDisplayName, getToolErrorText, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
@ -329,7 +329,7 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
<ToolTabbedContent
input={tool.input as ToolUIPart["input"]}
output={tool.result as ToolUIPart["output"]}
errorText={tool.status === 'error' ? 'Tool error' : undefined}
errorText={getToolErrorText(tool)}
/>
</ToolContent>
</Tool>

View file

@ -0,0 +1,302 @@
import { useEffect, useState } from 'react'
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { PublishDialog } from '@/components/apps/publish-dialog'
// App detail panel (spec §14): manifest info, provenance/publish state,
// bundled agents with enable toggles, rollback when available. Update/publish
// actions land with M3.
type AgentRow = {
slug: string
name: string
active: boolean
lastRunAt?: string
lastRunError?: string
}
export function AppDetail({ folder, onClose }: { folder: string; onClose: () => void }) {
const [app, setApp] = useState<rowboatApp.AppSummary | null>(null)
const [readme, setReadme] = useState<string | undefined>(undefined)
const [rollback, setRollback] = useState(false)
const [agents, setAgents] = useState<AgentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null)
const [showPublish, setShowPublish] = useState(false)
const [busyAction, setBusyAction] = useState<string | null>(null)
const runAction = async (name: string, fn: () => Promise<string | void>) => {
setBusyAction(name)
setError(null)
setNotice(null)
try {
const msg = await fn()
if (msg) setNotice(msg)
setReloadNonce((n) => n + 1)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyAction(null)
}
}
const checkForUpdate = () => runAction('check', async () => {
const r = await window.ipc.invoke('apps:checkUpdate', { folder })
setUpdateInfo(r)
return r.updateAvailable ? `v${r.latest} is available (you have v${r.current}).` : `Up to date (v${r.current}).`
})
const doUpdate = () => runAction('update', async () => {
try {
await window.ipc.invoke('apps:update', { folder })
return 'Updated.'
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
// D18 / modified-files confirmation flows (§12.3)
if (msg.includes('new_capabilities')) {
if (!window.confirm(`This update widens the app's access:\n${msg}\n\nProceed?`)) return
await window.ipc.invoke('apps:update', { folder, confirmNewCapabilities: true, confirmOverwriteModified: true })
return 'Updated (new capabilities confirmed).'
}
if (msg.includes('modified_files')) {
if (!window.confirm(`You modified files this update will overwrite:\n${msg}\n\nProceed?`)) return
await window.ipc.invoke('apps:update', { folder, confirmOverwriteModified: true })
return 'Updated (local modifications overwritten).'
}
throw e
}
})
const doRollback = () => runAction('rollback', async () => {
await window.ipc.invoke('apps:rollback', { folder })
return 'Rolled back to the previous version.'
})
const doUninstall = () => runAction('uninstall', async () => {
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
await window.ipc.invoke('apps:uninstall', { folder })
onClose()
})
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await window.ipc.invoke('apps:get', { folder })
if (cancelled) return
setApp(r.app)
setReadme(r.readme)
setRollback(r.rollbackAvailable)
const rows: AgentRow[] = []
for (const slug of r.app.agentSlugs) {
try {
const t = await window.ipc.invoke('bg-task:get', { slug })
if (t.task) {
rows.push({
slug,
name: t.task.name,
active: t.task.active,
lastRunAt: t.task.lastRunAt,
lastRunError: t.task.lastRunError,
})
}
} catch { /* not materialized yet */ }
}
if (!cancelled) setAgents(rows)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
}
})()
return () => { cancelled = true }
}, [folder, reloadNonce])
const toggleAgent = async (slug: string, active: boolean) => {
setAgents((prev) => prev.map((a) => (a.slug === slug ? { ...a, active } : a)))
try {
await window.ipc.invoke('bg-task:patch', { slug, partial: { active } })
} catch {
setReloadNonce((n) => n + 1) // revert to truth on failure
}
}
const runAgent = async (slug: string) => {
try {
await window.ipc.invoke('bg-task:run', { slug })
} catch { /* surfaced via bg-task UI */ }
}
const manifest = app?.manifest
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="flex-1 truncate text-sm font-semibold">{manifest?.name ?? folder}</span>
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
{notice && <div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground">{notice}</div>}
{!app ? (
<div className="text-muted-foreground">Loading</div>
) : (
<div className="space-y-5">
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">App</div>
{app.status === 'invalid' && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Invalid manifest: {app.manifestError}
</div>
)}
<InfoRow k="Version" v={manifest ? `v${manifest.version}` : '—'} />
<InfoRow k="Folder" v={app.folder} />
<InfoRow k="Origin" v={app.origin} mono />
{manifest?.description ? <p className="pt-1 text-muted-foreground">{manifest.description}</p> : null}
</section>
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Capabilities</div>
{manifest && manifest.capabilities.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{manifest.capabilities.map((c) => (
<span key={c} className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">{c}</span>
))}
</div>
) : (
<p className="text-muted-foreground">None declared this app cant use tools, LLM, or the copilot.</p>
)}
</section>
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Source</div>
{app.kind === 'installed' && app.install ? (
<>
<InfoRow k="Installed from" v={app.install.repo ?? app.install.sourceUrl ?? 'unknown'} mono />
<InfoRow k="Installed" v={new Date(app.install.installedAt).toLocaleString()} />
{app.install.updatedAt && <InfoRow k="Updated" v={new Date(app.install.updatedAt).toLocaleString()} />}
</>
) : app.publish ? (
<>
<p className="text-muted-foreground">Local app published to the Rowboat catalog.</p>
<InfoRow k="Repository" v={app.publish.repo} mono link={`https://github.com/${app.publish.repo}`} />
{app.publish.lastPublishedVersion && (
<InfoRow
k="Published"
v={`v${app.publish.lastPublishedVersion}`}
link={`https://github.com/${app.publish.repo}/releases/tag/v${app.publish.lastPublishedVersion}`}
/>
)}
</>
) : (
<p className="text-muted-foreground">Local app created on this machine.</p>
)}
<div className="flex flex-wrap gap-2 pt-1">
{app.kind === 'installed' && app.install?.repo && (
<button type="button" disabled={busyAction !== null}
onClick={() => void (updateInfo?.updateAvailable ? doUpdate() : checkForUpdate())}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<ArrowUpCircle className="size-3.5" />
{busyAction === 'check' || busyAction === 'update' ? 'Working…'
: updateInfo?.updateAvailable ? `Update to v${updateInfo.latest}` : 'Check for update'}
</button>
)}
{rollback && (
<button type="button" disabled={busyAction !== null} onClick={() => void doRollback()}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<RotateCcw className="size-3.5" /> Roll back
</button>
)}
{app.kind === 'local' && (
<button type="button" disabled={busyAction !== null} onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
</button>
)}
{app.kind === 'installed' && (
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
<Trash2 className="size-3.5" /> Uninstall
</button>
)}
</div>
</section>
<section className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Background agents</div>
{agents.length === 0 ? (
<p className="text-muted-foreground">No bundled agents.</p>
) : agents.map((a) => (
<div key={a.slug} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{a.name}</div>
<div className="truncate text-xs text-muted-foreground">
{a.lastRunError ? `Failed: ${a.lastRunError}` : a.lastRunAt ? `Last run ${new Date(a.lastRunAt).toLocaleString()}` : 'Never run'}
</div>
</div>
<button
type="button"
title="Run now"
onClick={() => void runAgent(a.slug)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Play className="size-3.5" />
</button>
<button
type="button"
role="switch"
aria-checked={a.active}
onClick={() => void toggleAgent(a.slug, !a.active)}
className={`relative h-5 w-9 rounded-full transition ${a.active ? 'bg-primary' : 'bg-muted'}`}
>
<span className={`absolute top-0.5 size-4 rounded-full bg-background shadow transition-all ${a.active ? 'left-[18px]' : 'left-0.5'}`} />
</button>
</div>
))}
</section>
{readme && (
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">README</div>
<pre className="whitespace-pre-wrap rounded-lg border border-border bg-muted/40 p-3 text-xs leading-relaxed">{readme}</pre>
</section>
)}
</div>
)}
</div>
{showPublish && app && (
<PublishDialog
folder={folder}
appName={manifest?.name ?? folder}
published={!!app.publish}
onClose={() => setShowPublish(false)}
onPublished={() => setReloadNonce((n) => n + 1)}
/>
)}
</div>
)
}
function InfoRow({ k, v, mono, link }: { k: string; v: string; mono?: boolean; link?: string }) {
return (
<div className="flex items-baseline gap-2">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{k}</span>
{link ? (
<a
href={link}
target="_blank"
rel="noreferrer"
className={`min-w-0 truncate text-primary hover:underline ${mono ? 'font-mono text-xs' : ''}`}
>
{v}
</a>
) : (
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
)}
</div>
)
}

View file

@ -0,0 +1,155 @@
import { useEffect, useState } from 'react'
import { ArrowLeft, BadgeCheck, ExternalLink, Info, RotateCw, UploadCloud } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { appOpened } from '@/lib/analytics'
import { AppDetail } from '@/components/apps/app-detail'
import { PublishDialog } from '@/components/apps/publish-dialog'
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
// open-in-browser, detail panel.
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
const [reloadNonce, setReloadNonce] = useState(0)
const [showDetail, setShowDetail] = useState(false)
const [showPublish, setShowPublish] = useState(false)
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
// surface a visible retry state instead of a silent blank pane.
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
const title = app.manifest?.name ?? app.folder
useEffect(() => {
appOpened(app.folder)
}, [app.folder])
// Reset the watchdog when the target changes (adjust-during-render pattern).
const [watchKey, setWatchKey] = useState(`${app.folder}:${reloadNonce}`)
if (watchKey !== `${app.folder}:${reloadNonce}`) {
setWatchKey(`${app.folder}:${reloadNonce}`)
setLoadState('loading')
}
useEffect(() => {
const timer = window.setTimeout(() => {
setLoadState((s) => (s === 'loading' ? 'stuck' : s))
}, 6000)
return () => window.clearTimeout(timer)
}, [watchKey])
// Stuck diagnosis: the usual cause is the apps server not being reachable
// yet (it starts with the main process; on a fresh launch or a quick
// relaunch the first iframe load can beat it). Say when the server itself
// is the problem.
const [serverDown, setServerDown] = useState(false)
useEffect(() => {
if (loadState !== 'stuck') return
let cancelled = false
void window.ipc.invoke('apps:serverStatus', {}).then((s) => {
if (!cancelled) setServerDown(!s.running)
}).catch(() => { /* status probe is best-effort */ })
return () => { cancelled = true }
}, [loadState])
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="size-4" />
Apps
</button>
<span className="flex-1 truncate text-sm font-medium">{title}</span>
<button
type="button"
title="Reload"
onClick={() => setReloadNonce((n) => n + 1)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RotateCw className="size-4" />
</button>
<button
type="button"
title="Open in browser"
onClick={() => window.open(app.origin, '_blank')}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ExternalLink className="size-4" />
</button>
{app.kind === 'local' && (
app.publish ? (
<button
type="button"
title={`Published as ${app.publish.repo} — view details`}
onClick={() => setShowDetail(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500"
>
<BadgeCheck className="size-4" /> Published
</button>
) : (
<button
type="button"
title="Publish this app"
onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
<UploadCloud className="size-4" /> Publish
</button>
)
)}
<button
type="button"
title="App details"
onClick={() => setShowDetail((v) => !v)}
className={`rounded-md p-1.5 hover:bg-accent hover:text-foreground ${showDetail ? 'text-foreground' : 'text-muted-foreground'}`}
>
<Info className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1">
<div className="relative min-w-0 flex-1">
<iframe
key={reloadNonce}
title={title}
src={`${app.origin}/`}
onLoad={() => setLoadState('ok')}
className="h-full w-full border-0 bg-background"
/>
{loadState === 'stuck' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
<div className="text-muted-foreground">
{serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'}
</div>
<button
type="button"
onClick={() => setReloadNonce((n) => n + 1)}
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
>
Retry
</button>
<div className="max-w-xs text-center text-xs text-muted-foreground">
If it still doesn&apos;t load, go back to Apps and open it again.
</div>
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
</div>
)}
</div>
{showDetail && (
<div className="w-80 shrink-0 border-l border-border">
<AppDetail folder={app.folder} onClose={() => setShowDetail(false)} />
</div>
)}
</div>
{showPublish && (
<PublishDialog
folder={app.folder}
appName={title}
onClose={() => setShowPublish(false)}
onPublished={() => setShowDetail(true)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,244 @@
import { useEffect, useState } from 'react'
import { Plus, RefreshCw } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { AppFrame } from '@/components/apps/app-frame'
import { CatalogTab } from '@/components/apps/catalog'
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
// AppSummary-driven; click opens the app full-height on its own origin.
type Theme = { accent: string; glow: string }
const THEMES: Theme[] = [
{ accent: '#FF4D8D', glow: 'rgba(255,77,141,0.45)' }, // Pink
{ accent: '#EF4444', glow: 'rgba(239,68,68,0.45)' }, // Red
{ accent: '#22C55E', glow: 'rgba(34,197,94,0.40)' }, // Emerald
{ accent: '#F59E0B', glow: 'rgba(245,158,11,0.42)' }, // Amber
{ accent: '#14B8A6', glow: 'rgba(20,184,166,0.40)' }, // Teal
{ accent: '#EC4899', glow: 'rgba(236,72,153,0.42)' }, // Rose
]
const PATTERNS = ['dots', 'grid', 'diagonal', 'radial', 'waves', 'mesh', 'cross', 'rings', 'zigzag', 'plus', 'checker', 'beams']
function hash(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
return Math.abs(h)
}
const themeForIndex = (i: number): Theme => THEMES[i % THEMES.length]
const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length]
const CARD_CSS = `
.ma-page {
container-type: inline-size;
--ma-bg:#f8f8f9;
--ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
--ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
--ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
--ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15);
--ma-shadow:0 1px 2px rgba(0,0,0,0.08);
--ma-title:#0d0e11; --ma-desc:rgba(0,0,0,0.6);
--ma-h1:#0d0e11; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42);
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5);
--ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4);
--ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24;
--ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%;
height:100%; overflow:auto; background:var(--ma-bg);
}
.dark .ma-page {
--ma-bg:#0b0b0d;
--ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
--ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
--ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
--ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12);
--ma-shadow:0 1px 2px rgba(0,0,0,0.35);
--ma-title:#f4f5f7; --ma-desc:rgba(255,255,255,0.66);
--ma-h1:#f4f5f7; --ma-sub:rgba(255,255,255,0.52); --ma-lastrun:rgba(255,255,255,0.38);
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5);
--ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38);
--ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
}
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); }
.ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); }
.ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
.ma-tab.on { color:var(--ma-title); border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--ma-title) 6%, transparent); }
.ma-banner { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:var(--ma-title); border-radius:12px; padding:10px 14px; font-size:13px; margin-bottom:16px; }
.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
.ma-card {
position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px;
border:1px solid var(--ma-border);
background:
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 34%),
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint), transparent) 0%, transparent 62%),
linear-gradient(158deg, var(--ma-card-from) 0%, var(--ma-card-mid) 52%, var(--ma-card-to) 100%);
padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden;
display:flex; flex-direction:column; isolation:isolate;
box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow);
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease;
}
.ma-card:hover {
border-color: var(--ma-border-hover);
background:
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 36%),
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%),
linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%);
}
.ma-card::before { content:''; position:absolute; inset:0; z-index:-1; opacity:var(--ma-pat-opacity); pointer-events:none; }
.ma-card::after {
content:''; position:absolute; top:-45%; right:-25%; width:75%; height:75%; z-index:-1;
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
opacity:var(--ma-glow-opacity); filter: blur(18px); pointer-events:none; transition: opacity .22s ease;
}
.ma-card:hover::after { opacity:var(--ma-glow-hover-opacity); }
.ma-pat-dots::before { background-image: radial-gradient(var(--accent) 1px, transparent 1.4px); background-size:16px 16px; }
.ma-pat-grid::before { background-image: linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size:26px 26px; }
.ma-pat-diagonal::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 14px); }
.ma-pat-radial::before { background-image: radial-gradient(circle at 78% 18%, var(--accent) 0%, transparent 55%); opacity:calc(var(--ma-pat-opacity) + 0.05); }
.ma-pat-waves::before { background-image: repeating-radial-gradient(circle at 50% -30%, transparent 0 20px, var(--accent) 20px 21px); }
.ma-pat-mesh::before { background-image: radial-gradient(circle at 12% 18%, var(--accent) 0%, transparent 42%), radial-gradient(circle at 88% 82%, var(--accent) 0%, transparent 42%); opacity:calc(var(--ma-pat-opacity) + 0.03); }
.ma-pat-cross::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 18px), repeating-linear-gradient(-45deg, var(--accent) 0 1px, transparent 1px 18px); }
.ma-pat-rings::before { background-image: repeating-radial-gradient(circle at 82% 20%, transparent 0 14px, var(--accent) 14px 15px); }
.ma-pat-zigzag::before { background-image: linear-gradient(135deg, var(--accent) 25%, transparent 25%), linear-gradient(225deg, var(--accent) 25%, transparent 25%); background-size: 22px 12px; background-position: 0 0, 11px 0; opacity:calc(var(--ma-pat-opacity) - 0.03); }
.ma-pat-plus::before { background-image: radial-gradient(var(--accent) 0.8px, transparent 1px), linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size: 24px 24px, 24px 24px, 24px 24px; background-position: 12px 12px, 0 11.5px, 11.5px 0; opacity:calc(var(--ma-pat-opacity) - 0.02); }
.ma-pat-checker::before { background-image: repeating-conic-gradient(var(--accent) 0% 25%, transparent 0% 50%); background-size: 26px 26px; opacity:calc(var(--ma-pat-opacity) - 0.04); }
.ma-pat-beams::before { background-image: repeating-linear-gradient(100deg, var(--accent) 0 2px, transparent 2px 34px); }
.ma-top { display:flex; justify-content:flex-end; gap:6px; }
.ma-badge {
display:inline-flex; align-items:center; height:22px; padding:0 10px; border-radius:999px;
font-size:9.5px; font-weight:600; letter-spacing:0.07em;
color: var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-badge-mix), transparent);
}
.ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); }
.ma-badge.err { color:#ef4444; background:rgba(239,68,68,.14); }
.ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; }
.ma-desc {
font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; }
.ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; }
.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ma-new {
width:100%; font:inherit; min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:8px; color:var(--ma-new-title); cursor:pointer; transition: border-color .2s ease, background .2s ease;
}
.ma-new:hover { border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--accent, #888) 6%, transparent); }
.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
.ma-empty { padding:36px 8px; text-align:center; color:var(--ma-sub); font-size:14px; grid-column:1/-1; }
@container (max-width: 380px) {
.ma-footer { flex-direction:column; align-items:flex-start; gap:6px; }
}
`
function Card({ app, index, onOpen }: { app: rowboatApp.AppSummary; index: number; onOpen: () => void }) {
const theme = themeForIndex(index)
const pattern = patternFor(app.folder)
const invalid = app.status === 'invalid'
return (
<button
type="button"
onClick={onOpen}
title={invalid ? app.manifestError : undefined}
className={`ma-card ma-pat-${pattern}`}
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
>
<div className="ma-top">
{invalid && <span className="ma-badge err">INVALID</span>}
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
</span>
</div>
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
<div className="ma-footer">
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
<span className="ma-lastrun">{app.folder}</span>
</div>
</button>
)
}
export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
initialAppFolder?: string | null
initialVersion?: number
onNewApp?: () => void
} = {}) {
const [tab, setTab] = useState<'mine' | 'catalog'>('mine')
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
const [serverError, setServerError] = useState<string | null>(null)
// Open a specific app when asked from outside (app-navigation open-app).
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
if (initialVersion !== appliedVersion) {
setAppliedVersion(initialVersion)
if (initialAppFolder) setSelectedFolder(initialAppFolder)
}
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const r = await window.ipc.invoke('apps:list', {})
if (cancelled) return
setApps(r.apps)
// Drop a selection whose app no longer exists (uninstalled while
// open). Left stale, a later reinstall makes this view yank the user
// into the app frame mid-flow — e.g. while they're in the catalog's
// post-install agent dialog.
setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur))
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
} catch (e) {
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
}
}
void load()
const interval = setInterval(load, 4000) // keep the grid fresh (copilot installs)
return () => { cancelled = true; clearInterval(interval) }
}, [initialVersion])
const selected = selectedFolder ? apps.find((a) => a.folder === selectedFolder) : undefined
if (selected) {
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
}
return (
<div className="ma-page">
<style>{CARD_CSS}</style>
<div className="ma-inner">
<h1 className="ma-h1">Apps</h1>
<p className="ma-sub">Apps that live inside Rowboat, powered by your agents and integrations.</p>
<div className="ma-tabs">
<button type="button" className={`ma-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
<button type="button" className={`ma-tab${tab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
</div>
{serverError && (
<div className="ma-banner">
<RefreshCw className="mr-1.5 inline size-3.5" /> Apps server unavailable: {serverError}
</div>
)}
{tab === 'catalog' ? (
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
) : (
<div className="ma-grid">
{apps.map((app, i) => (
<Card key={app.folder} app={app} index={i} onOpen={() => setSelectedFolder(app.folder)} />
))}
<button type="button" className="ma-new" onClick={onNewApp}>
<Plus className="size-5" />
<div className="ma-new-title">New app</div>
<div className="ma-new-hint">Describe one to the copilot</div>
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,381 @@
import { useEffect, useState } from 'react'
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
// Catalog tab (spec §14): search the registry, install with the D18 capability
// disclosure, install from a direct bundle URL.
type Preview = {
name?: string
version?: string
description?: string
capabilities?: string[]
agents?: string[]
updateSource?: 'github' | 'none'
url?: string // set for URL installs
}
function capabilityDescription(cap: string): string {
if (cap === 'llm') return 'use your AI models (spends your tokens)'
if (cap === 'copilot') return 'run the copilot agent on your behalf (tools + your knowledge)'
return `read and act on your ${cap} through your connected account`
}
/** D18 disclosure dialog: every declared capability + bundled agent, explicit confirm. */
function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: {
preview: Preview
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
const caps = preview.capabilities ?? []
const agents = preview.agents ?? []
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-1 text-base font-semibold">Install {preview.name} v{preview.version}?</div>
{preview.description && <p className="mb-3 text-sm text-muted-foreground">{preview.description}</p>}
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="mb-1.5 flex items-center gap-1.5 font-medium">
<ShieldAlert className="size-4 text-amber-500" /> This app will be able to:
</div>
{caps.length === 0 ? (
<p className="text-muted-foreground">Nothing it declares no capabilities (no tools, LLM, or copilot access).</p>
) : (
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
{caps.map((c) => <li key={c}><span className="font-medium text-foreground">{c}</span>: {capabilityDescription(c)}</li>)}
</ul>
)}
{agents.length > 0 && (
<div className="mt-2">
<div className="font-medium">Bundled background agents (installed disabled):</div>
<ul className="list-inside list-disc text-muted-foreground">
{agents.map((a) => <li key={a}>{a}</li>)}
</ul>
</div>
)}
</div>
{preview.updateSource === 'none' && (
<p className="mb-3 text-xs text-amber-600 dark:text-amber-400">Installed from a direct URL updates will be unavailable.</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={onConfirm} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Installing…' : 'Install'}
</button>
</div>
</div>
</div>
)
}
type ModelChoice = { provider: string; model: string }
/** Post-install opt-in (§8.3): bundled agents land disabled; without this
* prompt a fresh installer opens an empty app with no hint that the refresher
* exists, buried in bg-tasks. The model picker defaults to the host-pinned
* model but lets the user override before the first run. */
function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSkip }: {
appName: string
names: string[]
defaultModel?: ModelChoice
busy: boolean
onEnable: (model: ModelChoice | null) => void
onSkip: () => void
}) {
const [options, setOptions] = useState<Array<ModelChoice & { label: string }>>([])
const [selected, setSelected] = useState<string>(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '')
useEffect(() => {
void (async () => {
try {
const r = await window.ipc.invoke('models:list', null)
setOptions(r.providers.flatMap((p) => p.models.map((m) => ({
provider: p.id,
model: m.id,
label: `${m.name ?? m.id} (${p.name})`,
}))))
} catch { /* no picker — enable keeps the pinned model */ }
})()
}, [])
const choice = (): ModelChoice | null => {
const [provider, model] = selected.split('::')
return provider && model ? { provider, model } : null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-1 flex items-center gap-2 text-base font-semibold">
<Bot className="size-5" /> Turn on {names.length === 1 ? 'its background agent' : 'its background agents'}?
</div>
<p className="mb-3 text-sm text-muted-foreground">
{appName} ships {names.length === 1 ? 'an agent' : 'agents'} that keep{names.length === 1 ? 's' : ''} its
data fresh on a schedule, using your connected accounts and AI models. {names.length === 1 ? 'It is' : 'They are'} currently off.
</p>
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
{names.map((n) => <li key={n}>{n}</li>)}
</ul>
{options.length > 0 && (
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
Run with
<select value={selected} onChange={(e) => setSelected(e.target.value)}
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
)}
{options.map((o) => (
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
))}
</select>
</label>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onSkip} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Turning on…' : 'Turn on & run now'}
</button>
</div>
</div>
</div>
)
}
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
const [stale, setStale] = useState(false)
const [query, setQuery] = useState('')
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [urlDialog, setUrlDialog] = useState(false)
const [url, setUrl] = useState('')
const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[]; defaultModel?: ModelChoice } | null>(null)
const [enabling, setEnabling] = useState(false)
// Registry name → local folder, for apps already installed from the catalog.
const [installedByName, setInstalledByName] = useState<Map<string, string>>(new Map())
const loadInstalled = async () => {
try {
const r = await window.ipc.invoke('apps:list', {})
setInstalledByName(new Map(
r.apps.filter((a) => a.kind === 'installed' && a.install).map((a) => [a.install!.name, a.folder]),
))
} catch { /* cards just show Install */ }
}
useEffect(() => { void loadInstalled() }, [])
const load = async (force = false) => {
setError(null)
try {
const r = await window.ipc.invoke('apps:catalogIndex', { force })
setRecords(r.records)
setStale(r.stale)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
useEffect(() => { void load() }, [])
const search = async (q: string) => {
setQuery(q)
try {
const r = q.trim()
? await window.ipc.invoke('apps:catalogSearch', { query: q })
: await window.ipc.invoke('apps:catalogIndex', {})
setRecords(r.records)
} catch { /* keep current list */ }
}
const startInstall = async (name: string) => {
setError(null)
try {
const r = await window.ipc.invoke('apps:install', { name })
if (r.status === 'preview') setPreview({ ...r })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const startUrlPreview = async () => {
setError(null)
try {
const r = await window.ipc.invoke('apps:installFromUrl', { url: url.trim(), confirmed: false })
if (r.status === 'preview') setPreview({ ...r, url: url.trim() })
setUrlDialog(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const enableAgents = async (model: ModelChoice | null) => {
if (!agentPrompt) return
setEnabling(true)
try {
for (const slug of agentPrompt.slugs) {
await window.ipc.invoke('bg-task:patch', {
slug,
partial: { active: true, ...(model ? { model: model.model, provider: model.provider } : {}) },
})
// First run so the app opens with data; resolves when the run ends, so
// fire-and-forget.
void window.ipc.invoke('bg-task:run', { slug }).catch(() => { /* surfaced in bg-tasks */ })
}
} catch { /* patch failures land the user in the same place as "Not now" */ }
const folder = agentPrompt.folder
setAgentPrompt(null)
setEnabling(false)
onInstalled(folder)
}
const confirmInstall = async () => {
if (!preview) return
setBusy(true)
setError(null)
try {
const r = preview.url
? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true })
: await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true })
setPreview(null)
void loadInstalled()
if (r.status === 'installed' && r.app) {
if (r.app.agentSlugs.length > 0) {
// Offer to switch the bundled agents on (they install disabled).
let defaultModel: ModelChoice | undefined
const names = await Promise.all(r.app.agentSlugs.map(async (slug) => {
try {
const g = await window.ipc.invoke('bg-task:get', { slug })
if (g.task?.model && g.task.provider) defaultModel = { model: g.task.model, provider: g.task.provider }
return g.task?.name ?? slug
} catch {
return slug
}
}))
setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names, defaultModel })
} else {
onInstalled(r.app.folder)
}
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<div>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => void search(e.target.value)}
placeholder="Search the catalog…"
className="w-full rounded-lg border border-border bg-background py-2 pl-8 pr-3 text-sm outline-none focus:border-foreground/30"
/>
</div>
<button type="button" title="Install from URL"
onClick={() => setUrlDialog(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-accent">
<Link2 className="size-4" /> From URL
</button>
{stale && (
<button type="button" onClick={() => void load(true)}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm font-medium">
<RefreshCw className="size-4" /> Stale refresh
</button>
)}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>}
{records.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
</div>
) : (
<div className="space-y-2">
{records.map((r) => (
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-semibold">{r.name}</span>
<span className="text-xs text-muted-foreground">by {r.owner}</span>
</div>
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
</div>
{installedByName.has(r.name) ? (
<button type="button" title="Installed — open it"
onClick={() => onInstalled(installedByName.get(r.name)!)}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
<BadgeCheck className="size-4" /> Installed
</button>
) : (
<button type="button" onClick={() => void startInstall(r.name)}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
<Download className="size-4" /> Install
</button>
)}
</div>
))}
</div>
)}
{urlDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-2 text-base font-semibold">Install from URL</div>
<p className="mb-3 text-sm text-muted-foreground">Paste a direct https link to a <code>.rowboat-app</code> bundle (e.g. a GitHub release asset).</p>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com/owner/repo/releases/download/v1.0.0/name.rowboat-app"
className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-foreground/30"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setUrlDialog(false)}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={() => void startUrlPreview()} disabled={!url.trim().startsWith('https://')}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
Preview
</button>
</div>
</div>
</div>
)}
{preview && (
<InstallConfirmDialog
preview={preview}
busy={busy}
onConfirm={() => void confirmInstall()}
onCancel={() => setPreview(null)}
/>
)}
{agentPrompt && (
<EnableAgentsDialog
appName={agentPrompt.appName}
names={agentPrompt.names}
defaultModel={agentPrompt.defaultModel}
busy={enabling}
onEnable={(model) => void enableAgents(model)}
onSkip={() => {
const folder = agentPrompt.folder
setAgentPrompt(null)
onInstalled(folder)
}}
/>
)}
</div>
)
}

View file

@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from 'react'
import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react'
// Publish dialog (spec §14): device-flow sign-in → confirmation → step
// progress (§11.2 step names) → success links / typed error (name_taken gets
// an inline hint to rename and retry). With `published` the dialog runs the
// UPDATE path instead (§11.3: version bump + new release, no registry) —
// re-running first-publish on a published app always fails with name_taken.
type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error'
type Increment = 'patch' | 'minor' | 'major'
export function PublishDialog({ folder, appName, published, onClose, onPublished }: {
folder: string
appName: string
/** App already has a publish record — run publish-update instead. */
published?: boolean
onClose: () => void
onPublished: () => void
}) {
const [phase, setPhase] = useState<Phase>('auth')
const [login, setLogin] = useState<string | undefined>()
const [userCode, setUserCode] = useState('')
const [steps, setSteps] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [increment, setIncrement] = useState<Increment>('patch')
const [result, setResult] = useState<{ repoUrl?: string; releaseUrl: string; prUrl?: string; status: string; version?: string } | null>(null)
const pollTimer = useRef<number | null>(null)
useEffect(() => {
void (async () => {
const s = await window.ipc.invoke('githubAuth:status', {})
if (s.signedIn) {
setLogin(s.login)
setPhase('confirm')
}
})()
return () => { if (pollTimer.current) window.clearInterval(pollTimer.current) }
}, [])
useEffect(() => {
return window.ipc.on('apps:progress', ({ folder: f, step }) => {
if (f === folder) setSteps((prev) => (prev.includes(step) ? prev : [...prev, step]))
})
}, [folder])
const startSignIn = async () => {
setError(null)
try {
const r = await window.ipc.invoke('githubAuth:start', {})
setUserCode(r.userCode)
setPhase('device')
pollTimer.current = window.setInterval(() => {
void (async () => {
const p = await window.ipc.invoke('githubAuth:poll', {})
if (p.status === 'authorized') {
if (pollTimer.current) window.clearInterval(pollTimer.current)
setLogin(p.login)
setPhase('confirm')
} else if (p.status === 'expired' || p.status === 'denied') {
if (pollTimer.current) window.clearInterval(pollTimer.current)
setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.')
setPhase('auth')
}
})().catch((e: unknown) => {
// A hard failure (bad client config, network) must surface, not spin.
if (pollTimer.current) window.clearInterval(pollTimer.current)
setError(e instanceof Error ? e.message : String(e))
setPhase('auth')
})
// Heartbeat only — core paces the actual GitHub requests to the
// flow's required interval (and skips the request when it's too soon).
}, 2000)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const publish = async () => {
setPhase('publishing')
setError(null)
setSteps([])
try {
if (published) {
const r = await window.ipc.invoke('apps:publishUpdate', { folder, increment })
setResult({ releaseUrl: r.releaseUrl, status: 'published', version: r.version })
} else {
const r = await window.ipc.invoke('apps:publish', { folder })
setResult(r)
}
setPhase('done')
onPublished()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setPhase('error')
}
}
const STEP_LABELS: Record<string, string> = {
packaged: 'Packaged bundle',
repo_created: 'Created GitHub repo',
source_pushed: 'Pushed source',
release_created: 'Created release',
assets_uploaded: 'Uploaded assets',
registered: 'Opened registry PR',
polling: 'Waiting for registry validation…',
published: 'Published',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-3 flex items-center gap-2 text-base font-semibold">
<Github className="size-5" /> Publish {appName}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
{error.includes('name_taken') && <div className="mt-1 text-xs">That name is taken rename the app in <code>rowboat-app.json</code> and retry.</div>}
</div>}
{phase === 'auth' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">Publishing creates a public GitHub repo under your account, uploads the app as a release, and lists it in the Rowboat catalog. A generated MIT LICENSE is added if your app has none.</p>
<button type="button" onClick={() => void startSignIn()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Sign in with GitHub
</button>
</div>
)}
{phase === 'device' && (
<div className="space-y-3 text-center text-sm">
<p className="text-muted-foreground">Enter this code on the GitHub page that just opened:</p>
<div className="select-all rounded-lg border border-border bg-muted/40 py-3 font-mono text-2xl font-bold tracking-widest">{userCode}</div>
<p className="flex items-center justify-center gap-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Waiting for authorization</p>
</div>
)}
{phase === 'confirm' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
Signed in as <span className="font-medium text-foreground">@{login}</span>.{' '}
{published
? <>This will publish a new version of <span className="font-medium text-foreground">{appName}</span> (a new release on the existing repo).</>
: <>This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</>}
</p>
{published && (
<label className="flex items-center gap-2 text-muted-foreground">
Version bump
<select value={increment} onChange={(e) => setIncrement(e.target.value as Increment)}
className="rounded-md border border-border bg-background px-2 py-1 text-sm">
<option value="patch">patch</option>
<option value="minor">minor</option>
<option value="major">major</option>
</select>
</label>
)}
<button type="button" onClick={() => void publish()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
{published ? 'Publish update' : 'Publish'}
</button>
</div>
)}
{published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-3 text-sm">
{phase === 'publishing' && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Publishing update
</p>
)}
{phase === 'done' && result && (
<div className="space-y-1">
<p className="flex items-center gap-2"><CheckCircle2 className="size-4 text-green-600" /> Published v{result.version}</p>
<div className="text-xs">Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
</div>
)}
</div>
)}
{!published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-1.5 text-sm">
{Object.entries(STEP_LABELS).map(([key, label]) => {
const doneStep = steps.includes(key) || phase === 'done'
const active = phase === 'publishing' && !doneStep && steps[steps.length - 1] !== key
if (key === 'published' && phase !== 'done') return null
return (
<div key={key} className="flex items-center gap-2">
{doneStep
? <CheckCircle2 className="size-4 text-green-600" />
: phase === 'error'
? <XCircle className="size-4 text-muted-foreground/40" />
: <Loader2 className={`size-4 ${active ? 'text-muted-foreground/40' : 'animate-spin text-muted-foreground'}`} />}
<span className={doneStep ? '' : 'text-muted-foreground'}>{label}</span>
</div>
)
})}
{phase === 'done' && result && (
<div className="mt-3 space-y-1 text-xs">
<div>Repo: <a className="text-primary underline" href={result.repoUrl} target="_blank" rel="noreferrer">{result.repoUrl}</a></div>
<div>Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
{result.status === 'pending' && result.prUrl && (
<div className="text-amber-600 dark:text-amber-400">Registry validation still pending track it at <a className="underline" href={result.prUrl} target="_blank" rel="noreferrer">the PR</a>.</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" onClick={onClose}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
{phase === 'done' ? 'Done' : 'Close'}
</button>
</div>
</div>
</div>
)
}

View file

@ -4,10 +4,9 @@ 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'
import type { Run } from '@x/shared/dist/runs.js'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
@ -16,9 +15,10 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
import type { ConversationItem } from '@/lib/chat-conversation'
import { runLogToConversation } from '@/lib/run-to-conversation'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
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 +270,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 +303,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 +315,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 +423,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 +676,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 +700,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 +735,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>
)
}
@ -769,10 +968,9 @@ function SetupTab({
// Runs history tab — list + drill-down transcript view
//
// Source of truth: `bg-tasks/<slug>/runs.log` — a plain-text file with one
// runId per line (newest first). The actual transcripts live at the global
// `$WorkDir/runs/<runId>.jsonl`, so this tab fetches runIds via the bg-task
// IPC, then loads each Run through the standard `runs:fetch`. No bg-task-
// specific transcript path or schema needed.
// turn id per line (newest first). Transcripts live in the turn runtime's
// storage; this tab fetches ids via the bg-task IPC, then loads each through
// the shared agent-transcript loader (turn-first, legacy-run fallback).
// ---------------------------------------------------------------------------
interface RunRowSummary {
@ -783,27 +981,6 @@ interface RunRowSummary {
error?: string
}
// Pull the bits we want to display for a row out of a full Run's event log.
function summarizeRun(run: z.infer<typeof Run>): RunRowSummary {
const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase }
for (const event of run.log) {
if (event.type === 'error' && typeof event.error === 'string') {
out.error = event.error
} else if (event.type === 'message' && event.message?.role === 'assistant') {
const content = event.message.content
if (typeof content === 'string') {
out.summary = content
} else if (Array.isArray(content)) {
const text = content
.filter((p) => p.type === 'text')
.map((p) => ('text' in p ? p.text : ''))
.join('')
if (text) out.summary = text
}
}
}
return out
}
function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) {
const [rows, setRows] = useState<RunRowSummary[]>([])
@ -815,19 +992,25 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask })
setLoading(true)
try {
const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 })
// Fetch each Run in parallel via the canonical IPC. Runs whose
// jsonl no longer exists (deleted manually, never written, …) are
// dropped silently.
// Fetch transcripts in parallel (turn-first, legacy-run
// fallback). Ids whose files no longer exist keep a bare row so
// the user knows the run happened.
const settled = await Promise.allSettled(
runIds.map(runId => window.ipc.invoke('runs:fetch', { runId }))
runIds.map(runId => fetchAgentRunTranscript(runId))
)
const next: RunRowSummary[] = []
for (let i = 0; i < settled.length; i++) {
const r = settled[i]
if (r.status === 'fulfilled' && r.value) {
next.push(summarizeRun(r.value))
if (r.status === 'fulfilled') {
const t = r.value
next.push({
runId: t.id,
...(t.createdAt === undefined ? {} : { createdAt: t.createdAt }),
...(t.trigger === undefined ? {} : { trigger: t.trigger }),
...(t.summary === undefined ? {} : { summary: t.summary }),
...(t.error === undefined ? {} : { error: t.error }),
})
} else {
// Keep the row visible with just the id so the user knows it exists.
next.push({ runId: runIds[i] })
}
}
@ -933,7 +1116,7 @@ function RunTranscriptView({
isInFlight: boolean
onBack: () => void
}) {
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -943,15 +1126,13 @@ function RunTranscriptView({
setError(null)
void (async () => {
try {
// Bg-task transcripts now live at the global runs/ location —
// same path resolution as every other run, no special handling.
const r = await window.ipc.invoke('runs:fetch', { runId })
const t = await fetchAgentRunTranscript(runId)
if (cancelled) return
setRun(r)
setTranscript(t)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
setRun(null)
setTranscript(null)
} finally {
if (!cancelled) setLoading(false)
}
@ -959,8 +1140,8 @@ function RunTranscriptView({
return () => { cancelled = true }
}, [runId])
const summary = run ? summarizeRun(run) : undefined
const items: ConversationItem[] = run ? runLogToConversation(run.log) : []
const summary = transcript ?? undefined
const items: ConversationItem[] = transcript?.items ?? []
return (
<div className="flex flex-1 flex-col overflow-hidden">
@ -1020,10 +1201,10 @@ function RunTranscriptView({
Couldn&apos;t load transcript: {error}
</div>
)}
{run && !loading && items.length === 0 && (
{transcript && !loading && items.length === 0 && (
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
)}
{run && !loading && items.length > 0 && (
{transcript && !loading && items.length > 0 && (
<CompactConversation items={items} />
)}
</div>

View file

@ -1,7 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
import type { HttpAuthRequest } from '@x/shared/dist/browser-control.js'
import { TabBar } from '@/components/tab-bar'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
/**
@ -86,9 +98,80 @@ const getBrowserTabTitle = (tab: BrowserTabState) => {
}
}
/**
* Credential prompt for HTTP basic/proxy auth challenges raised by pages in
* the embedded browser. Rendered as a regular app dialog: BrowserPane already
* hides the native WebContentsView whenever a dialog overlay is open, so the
* prompt is never obscured by the page that triggered it.
*/
function BrowserHttpAuthDialog({
request,
onSubmit,
onCancel,
}: {
request: HttpAuthRequest
onSubmit: (username: string, password: string) => void
onCancel: () => void
}) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
// Basic auth allows an empty username (token-style `curl -u :TOKEN`), so the
// only invalid submission is fully empty. The server decides the rest.
const canSubmit = username.length > 0 || password.length > 0
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
onSubmit(username, password)
}
return (
<Dialog open onOpenChange={(open) => { if (!open) onCancel() }}>
<DialogContent className="w-[min(24rem,calc(100%-2rem))] max-w-sm">
<DialogHeader>
<DialogTitle>Sign in</DialogTitle>
<DialogDescription>
{request.isProxy
? `The proxy ${request.host} requires a username and password.`
: `${request.host} requires a username and password.`}
{request.realm ? ` (${request.realm})` : ''}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<Input
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
Sign in
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
const [authQueue, setAuthQueue] = useState<HttpAuthRequest[]>([])
const activeTabIdRef = useRef<string | null>(null)
const addressFocusedRef = useRef(false)
@ -121,6 +204,51 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
return cleanup
}, [applyState])
// Mirror of authQueue for the unmount handler, which must read the latest
// queue without re-subscribing on every change.
const authQueueRef = useRef<HttpAuthRequest[]>([])
useEffect(() => {
authQueueRef.current = authQueue
}, [authQueue])
useEffect(() => {
const offRequest = window.ipc.on('browser:httpAuthRequest', (incoming) => {
setAuthQueue((queue) => [...queue, incoming as HttpAuthRequest])
})
// Main resolved a challenge on its own (timeout, or its tab/window was
// destroyed) — drop the corresponding dialog so it can't linger over an
// unrelated page with a submit that would no-op.
const offResolved = window.ipc.on('browser:httpAuthResolved', (incoming) => {
const { requestId } = incoming as { requestId: string }
setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId))
})
return () => {
offRequest()
offResolved()
// Cancel anything still pending so the main-process login callbacks and
// timers are freed immediately instead of waiting out the timeout.
for (const request of authQueueRef.current) {
void window.ipc.invoke('browser:httpAuthResponse', { requestId: request.requestId })
}
}
}, [])
const respondToAuth = useCallback(
(requestId: string, credentials: { username: string; password: string } | null) => {
setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId))
// Omit username to cancel; include it (even empty) to submit.
void window.ipc.invoke(
'browser:httpAuthResponse',
credentials
? { requestId, username: credentials.username, password: credentials.password }
: { requestId },
)
},
[],
)
const activeAuthRequest = authQueue[0] ?? null
const setViewVisible = useCallback((visible: boolean) => {
if (viewVisibleRef.current === visible) return
viewVisibleRef.current = visible
@ -420,6 +548,17 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
className="relative min-h-0 min-w-0 flex-1"
data-browser-viewport
/>
{activeAuthRequest && (
<BrowserHttpAuthDialog
key={activeAuthRequest.requestId}
request={activeAuthRequest}
onSubmit={(username, password) =>
respondToAuth(activeAuthRequest.requestId, { username, password })
}
onCancel={() => respondToAuth(activeAuthRequest.requestId, null)}
/>
)}
</div>
)
}

View file

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

View file

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

View file

@ -91,11 +91,28 @@ interface ChatMessageAttachmentsProps {
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
if (attachments.length === 0) return null
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
const videoFrames = attachments.filter((attachment) => attachment.isVideoFrame)
const imageAttachments = attachments.filter(
(attachment) => !attachment.isVideoFrame && isImageMime(attachment.mimeType)
)
const fileAttachments = attachments.filter(
(attachment) => !attachment.isVideoFrame && !isImageMime(attachment.mimeType)
)
return (
<div className={cn('flex flex-col items-end gap-2', className)}>
{videoFrames.length > 0 && (
<div className="flex max-w-[340px] flex-wrap justify-end gap-1">
{videoFrames.map((frame, index) => (
<img
key={`frame-${index}`}
src={frame.thumbnailUrl}
alt={`Camera frame ${index + 1}`}
className="h-12 w-auto rounded-md border border-border/60 bg-muted object-cover"
/>
))}
</div>
)}
{imageAttachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{imageAttachments.map((attachment, index) => (

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@ -37,7 +37,7 @@ 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 PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatInputWithMentions, type CallPreset, 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'
@ -51,6 +51,7 @@ import {
getWebSearchCardData,
getComposioConnectCardData,
getToolDisplayName,
getToolErrorText,
groupConversationItems,
isChatMessage,
isErrorMessage,
@ -142,6 +143,10 @@ interface ChatSidebarProps {
chatTabStates?: Record<string, ChatTabViewState>
viewportAnchors?: Record<string, ChatViewportAnchorState>
isProcessing: boolean
// Actively working (sessions runtime). When provided, drives the shimmer
// instead of isProcessing so waiting on a permission/ask-human doesn't
// render a "Thinking…" under the card.
isThinking?: boolean
isStopping?: boolean
onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
@ -155,6 +160,13 @@ interface ChatSidebarProps {
onDraftChangeForTab?: (tabId: string, text: string) => void
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
workDirByTab?: Record<string, string | null>
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
/**
* Set while a Rowboat-mode code session owns this pane: the chat is pinned to
* the session, so the chat switcher / new-chat / history affordances hide.
*/
pinnedToCodeSession?: { title: string } | null
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
@ -170,16 +182,16 @@ 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
ttsEnabled?: boolean
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
inCall?: boolean
onStartCall?: (preset: CallPreset) => void
onEndCall?: () => void
callAvailable?: boolean
onComposioConnected?: (toolkitSlug: string) => void
}
@ -203,6 +215,7 @@ export function ChatSidebar({
chatTabStates = {},
viewportAnchors = {},
isProcessing,
isThinking,
isStopping,
onStop,
onSubmit,
@ -216,6 +229,8 @@ export function ChatSidebar({
onDraftChangeForTab,
onSelectedModelChangeForTab,
workDirByTab = {},
codeSessionLocks = {},
pinnedToCodeSession = null,
onWorkDirChangeForTab,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
@ -231,15 +246,15 @@ export function ChatSidebar({
isRecording,
recordingText,
recordingState,
audioLevelsRef,
onStartRecording,
onSubmitRecording,
onCancelRecording,
voiceAvailable,
ttsAvailable,
ttsEnabled,
ttsMode,
onToggleTts,
onTtsModeChange,
inCall,
onStartCall,
onEndCall,
callAvailable,
onComposioConnected,
}: ChatSidebarProps) {
const { state: sidebarState } = useSidebar()
@ -362,7 +377,14 @@ export function ChatSidebar({
}
try {
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
// Session-first (new runtime); legacy runs fallback covers old
// background tabs until stage 7 removes the runs runtime.
let result: { success: boolean; error?: string }
try {
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
} catch {
result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
}
if (result.success) {
toast.success('Chat log saved')
} else if (result.error) {
@ -463,7 +485,7 @@ export function ChatSidebar({
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const errorText = getToolErrorText(item)
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
@ -555,17 +577,34 @@ export function ChatSidebar({
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
}}
>
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
{pinnedToCodeSession ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-1.5 px-3 py-2 text-sm font-medium">
<Pin className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{pinnedToCodeSession.title}</span>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
Coding session
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
This chat is pinned to the coding session leave the Code view to switch chats.
</TooltipContent>
</Tooltip>
) : (
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@ -646,9 +685,6 @@ export function ChatSidebar({
{!tabHasConversation ? (
<ChatEmptyState
wide={isMaximized}
recentRuns={recentRuns}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
) : (
@ -700,7 +736,7 @@ export function ChatSidebar({
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}
isProcessing={isActive && (isThinking ?? isProcessing)}
response={response}
/>
)}
@ -717,7 +753,7 @@ export function ChatSidebar({
key={request.toolCallId}
query={request.query}
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing}
isProcessing={isActive && (isThinking ?? isProcessing)}
/>
))}
@ -729,7 +765,7 @@ export function ChatSidebar({
</Message>
)}
{isActive && isProcessing && !tabState.currentAssistantMessage && (
{isActive && (isThinking ?? isProcessing) && !tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
@ -779,18 +815,19 @@ 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}
voiceAvailable={isActive && voiceAvailable}
ttsAvailable={isActive && ttsAvailable}
ttsEnabled={ttsEnabled}
ttsMode={ttsMode}
onToggleTts={isActive ? onToggleTts : undefined}
onTtsModeChange={isActive ? onTtsModeChange : undefined}
inCall={inCall}
onStartCall={isActive ? onStartCall : undefined}
onEndCall={isActive ? onEndCall : undefined}
callAvailable={callAvailable}
/>
</div>
)

View 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
}
}

View file

@ -0,0 +1,28 @@
import type { CodingAgent } from '@x/shared/src/code-mode.js'
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
// Model + effort choices for a coding agent, discovered live from the engine
// (the same list `/model` shows) via the main process, which caches per agent.
// We memoize the in-flight/resolved promise per agent here too so reopening the
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
// just falls back to the engine default.
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
let pending = cache.get(agent)
if (!pending) {
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
cache.set(agent, pending)
}
return pending
}
// Always offer a Default fallback even before options load (or if discovery fails).
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
}
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
}

View file

@ -0,0 +1,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, getToolErrorText, 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 ?? []} error={getToolErrorText(item)} 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>
)
}

View 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>
)
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,372 @@
import { useEffect, useState } from 'react'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ProjectRow } from './use-code-sessions'
type AgentStatus = { installed: boolean; signedIn: boolean }
type ModelOption = { provider: string; model: string }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask every time',
'auto-approve-reads': 'Auto-approve reads',
yolo: 'Auto-approve everything (YOLO)',
}
// Models the user can pick for Rowboat-mode turns — mirrors the chat
// composer's loading: gateway list when signed in, models.json otherwise.
async function loadModelOptions(): Promise<ModelOption[]> {
try {
const oauth = await window.ipc.invoke('oauth:getState', null)
const connected = oauth.config?.rowboat?.connected ?? false
if (connected) {
const listResult = await window.ipc.invoke('models:list', null)
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
?.find((p) => p.id === 'rowboat')
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
}
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
const models: ModelOption[] = []
if (parsed?.providers) {
for (const [flavor, entry] of Object.entries(parsed.providers)) {
const e = entry as Record<string, unknown>
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
const singleModel = typeof e.model === 'string' ? e.model : ''
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) models.push({ provider: flavor, model })
}
}
}
return models
} catch {
return []
}
}
export function NewSessionDialog({
projectRow,
open,
onOpenChange,
onCreated,
}: {
projectRow: ProjectRow | null
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (session: CodeSession) => void
}) {
const [agentStatus, setAgentStatus] = useState<{ claude: AgentStatus; codex: AgentStatus } | null>(null)
const [agent, setAgent] = useState<CodingAgent>('claude')
// 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>
)
}

View file

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

View file

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

View file

@ -0,0 +1,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' }}
/>
)
}

View 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,
}
}

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Circle,
CircleDot,
@ -9,6 +10,7 @@ import {
Pencil,
Search,
ShieldQuestion,
Sparkles,
Terminal,
Trash2,
Wrench,
@ -16,7 +18,8 @@ import {
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'
import { getToolErrorText, toToolState, type ToolCall } from '@/lib/chat-conversation'
import { clearCodeRunBuffer, useCodeRunFeed } from '@/lib/code-run-feed'
// ── Timeline reduction ──────────────────────────────────────────────
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
@ -28,7 +31,7 @@ type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?:
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
type Row = TextRow | ToolRow | PlanRow | PermRow
function reduceEvents(events: CodeRunEvent[]): Row[] {
export function reduceEvents(events: CodeRunEvent[]): Row[] {
const rows: Row[] = []
const toolIdx = new Map<string, number>()
let planIdx = -1
@ -87,7 +90,8 @@ function reduceEvents(events: CodeRunEvent[]): Row[] {
return rows
}
function toolKindIcon(kind?: string) {
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" />
@ -107,9 +111,28 @@ function planMarker(status?: string) {
const basename = (p: string) => p.split(/[\\/]/).pop() || p
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
export function CodingRunTimeline({
events,
error,
onOpenDiff,
}: {
events: CodeRunEvent[]
error?: string
// 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) {
if (error) {
return (
<div className="px-4 py-3">
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
</div>
</div>
)
}
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent</div>
}
return (
@ -117,28 +140,45 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
{rows.map((row) => {
if (row.kind === 'text') {
return (
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
<p key={row.id} className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/90">
{row.text}
</p>
)
}
if (row.kind === 'tool') {
const running = row.status !== 'completed' && row.status !== 'failed'
const failed = 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)}
{running ? (
<Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : failed ? (
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
) : (
<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) => (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
onOpenDiff ? (
<button
key={d}
type="button"
onClick={() => onOpenDiff(d)}
className="truncate text-left font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
title={d}
>
{basename(d)}
</button>
) : (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
)
))}
</div>
)}
@ -168,6 +208,12 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
</div>
)
})}
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
</div>
)}
</div>
)
}
@ -237,12 +283,23 @@ export function CodingRunBlock({
(item.result as { agent?: string } | undefined)?.agent ??
(item.input as { agent?: string } | undefined)?.agent
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
const error = getToolErrorText(item)
// Timeline source: the durable record (item.codeRunEvents — the settle-time
// batch, or the legacy path's inline accumulation) wins; while it's absent
// the live CodeRunFeed buffer streams the run in real time.
const liveEvents = useCodeRunFeed(item.id)
const durableEvents = item.codeRunEvents
const events = durableEvents?.length ? durableEvents : liveEvents
// Once the durable batch has landed the buffer is redundant — drop it.
useEffect(() => {
if (durableEvents?.length) clearCodeRunBuffer(item.id)
}, [durableEvents?.length, item.id])
return (
<>
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} />
<CodingRunTimeline events={events} error={error} />
</ToolContent>
</Tool>
{item.pendingCodePermission && (

View file

@ -7,6 +7,7 @@ import {
isErrorMessage,
isToolCall,
getToolDisplayName,
getToolErrorText,
toToolState,
normalizeToolOutput,
} from '@/lib/chat-conversation'
@ -62,7 +63,7 @@ function CompactToolRow({ tool }: { tool: ToolCall }) {
const [open, setOpen] = useState(false)
const title = getToolDisplayName(tool)
const state = toToolState(tool.status)
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
const errorText = getToolErrorText(tool)
return (
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />

View file

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

View file

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

View 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&apos;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&apos;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>
)
}

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
ArrowLeft,
ChevronRight,
@ -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

View file

@ -11,11 +11,9 @@ import {
ChevronDown, ChevronRight,
} from 'lucide-react'
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
import type { Run } from '@x/shared/dist/runs.js'
import type z from 'zod'
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
import { runLogToConversation } from '@/lib/run-to-conversation'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { CompactConversation } from '@/components/compact-conversation'
export type OpenLiveNotePanelDetail = {
@ -661,7 +659,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
}
function LastRunTab({ live }: { live: LiveNote }) {
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loadingRun, setLoadingRun] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
@ -669,7 +667,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
useEffect(() => {
if (!runId) {
setRun(null)
setTranscript(null)
setFetchError(null)
setLoadingRun(false)
return
@ -679,13 +677,13 @@ function LastRunTab({ live }: { live: LiveNote }) {
setFetchError(null)
void (async () => {
try {
const r = await window.ipc.invoke('runs:fetch', { runId })
const t = await fetchAgentRunTranscript(runId)
if (cancelled) return
setRun(r)
setTranscript(t)
} catch (err) {
if (cancelled) return
setFetchError(err instanceof Error ? err.message : String(err))
setRun(null)
setTranscript(null)
} finally {
if (!cancelled) setLoadingRun(false)
}
@ -704,7 +702,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
}
const isError = !!live.lastRunError
const items = run ? runLogToConversation(run.log) : []
const items = transcript?.items ?? []
return (
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) {
Couldn't load transcript: {fetchError}
</div>
)}
{run && !loadingRun && items.length === 0 && (
{transcript && !loadingRun && items.length === 0 && (
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
)}
{run && !loadingRun && items.length > 0 && (
{transcript && !loadingRun && items.length > 0 && (
<CompactConversation items={items} />
)}
</div>

View file

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

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react'
import { Streamdown } from 'streamdown'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings'
const CALENDAR_DIR = 'calendar_sync'
const UPCOMING_MAX_DAYS = 4 // today + next 3
declare global {
interface Window {
__pendingMeetingPrepCreate?: { prompt: string }
}
}
// Mirrors the `meeting-prep:resolve` IPC response shape.
type PrepNote = {
path: string
name: string
role?: string
organization?: string
markdown: string
}
type PrepAttendee = {
label: string
email?: string
displayName?: string
note: PrepNote | null
}
type PrepOrg = {
path: string
name: string
markdown: string
}
type PrepResult = {
attendees: PrepAttendee[]
organizations: PrepOrg[]
prepNote: { path: string; brief: string } | null
matchedCount: number
unmatchedCount: number
}
type MeetingNoteRow = {
path: string
name: string
@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] {
}).filter((part) => part.text.length > 0)
}
function UpcomingEvents() {
// Hand the unmatched attendee off to the Copilot to research + create a note.
function requestCreateNote(attendee: PrepAttendee, meetingSummary: string) {
const who = attendee.displayName || attendee.label
const email = attendee.email ? ` <${attendee.email}>` : ''
window.__pendingMeetingPrepCreate = {
prompt: `Create a person note in my knowledge base for ${who}${email}. They're attending my "${meetingSummary}" meeting. Pull together what you know about them from my emails, past meetings, and calendar.`,
}
window.dispatchEvent(new Event('meeting-prep:create-note'))
}
// One note row (used for both people and organizations): a clickable row that
// navigates to the note. The markdown is NOT rendered inline in the card.
function PrepNoteRow({ title, subtitle, path, onOpenNote }: {
title: string
subtitle?: string
path: string
onOpenNote: (path: string) => void
}) {
return (
<button
type="button"
onClick={() => onOpenNote(path)}
title={`Open ${title}`}
className="flex w-full items-center gap-3 border-b px-5 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[12.5px] font-semibold text-foreground">{title}</span>
{subtitle ? <span className="truncate text-[11px] text-muted-foreground">{subtitle}</span> : null}
</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
</button>
)
}
function PrepAttendeeNote({ attendee, onOpenNote }: { attendee: PrepAttendee; onOpenNote: (path: string) => void }) {
const note = attendee.note
if (!note) return null
const subtitle = [note.role, note.organization].filter(Boolean).join(' · ')
return <PrepNoteRow title={note.name} subtitle={subtitle || undefined} path={note.path} onOpenNote={onOpenNote} />
}
function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) {
const [open, setOpen] = useState(false)
if (attendees.length === 0) return null
return (
<div className="border-t bg-muted/20">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/40"
>
{open ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="size-4 shrink-0 text-muted-foreground" />}
<span className="text-xs font-medium text-muted-foreground">
{attendees.length} {attendees.length === 1 ? 'other' : 'others'} no notes yet
</span>
</button>
{open ? (
<div className="flex flex-col gap-1 px-5 pb-3 pl-12">
{attendees.map((att, idx) => (
<div key={`${att.email ?? att.label}-${idx}`} className="flex items-center justify-between gap-3 py-1">
<span className="min-w-0 truncate text-sm text-foreground">{att.label}</span>
<button
type="button"
onClick={() => requestCreateNote(att, meetingSummary)}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
<UserPlus className="size-3.5" />
Create note
</button>
</div>
))}
</div>
) : null}
</div>
)
}
// Inline prep for a single event: resolves the attendees against the knowledge
// base and renders their notes directly beneath the event row. Re-resolves when
// a person note changes (e.g. after "Create note") so it stays fresh.
function InlineMeetingPrep({ event, onOpenNote }: { event: UpcomingEvent; onOpenNote: (path: string) => void }) {
const [prep, setPrep] = useState<PrepResult | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const attendees = event.attendees.map((a) => ({ email: a.email, displayName: a.displayName, self: a.self }))
const result = await window.ipc.invoke('meeting-prep:resolve', { attendees, eventId: event.id })
if (!cancelled) setPrep(result)
} catch (err) {
console.error('Meeting prep failed:', err)
if (!cancelled) setPrep(null)
}
}
void run()
return () => {
cancelled = true
}
}, [event.id, refreshTick])
// Refresh when a People note is created/changed so newly-created notes appear.
useEffect(() => {
const isPeoplePath = (p: string | undefined) =>
typeof p === 'string' && p.startsWith('knowledge/People/')
const cleanup = window.ipc.on('workspace:didChange', (e) => {
switch (e.type) {
case 'created':
case 'changed':
case 'deleted':
if (isPeoplePath(e.path)) setRefreshTick((t) => t + 1)
break
case 'moved':
if (isPeoplePath(e.from) || isPeoplePath(e.to)) setRefreshTick((t) => t + 1)
break
case 'bulkChanged':
if (!e.paths || e.paths.some(isPeoplePath)) setRefreshTick((t) => t + 1)
break
}
})
return cleanup
}, [])
if (!prep || prep.attendees.length === 0) return null
const matched = prep.attendees.filter((a) => a.note)
const unmatched = prep.attendees.filter((a) => !a.note)
return (
<div className="bg-muted/10">
{prep.prepNote && prep.prepNote.brief ? (
<div className="border-b px-5 pb-3 pt-3">
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 [&_p]:text-[12.5px] [&_li]:text-[12.5px]">
{prep.prepNote.brief}
</Streamdown>
<button
type="button"
onClick={() => onOpenNote(prep.prepNote!.path)}
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<FileText className="size-3.5" />
Open full prep
</button>
</div>
) : null}
<div className="flex items-center gap-1.5 px-5 pb-1 pt-2.5">
<UsersRound className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">People</span>
</div>
{matched.map((att, idx) => (
<PrepAttendeeNote key={att.note!.path + idx} attendee={att} onOpenNote={onOpenNote} />
))}
<PrepUnmatchedSection attendees={unmatched} meetingSummary={event.summary} />
{prep.organizations.length > 0 ? (
<>
<div className="px-5 pb-1 pt-2.5">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{prep.organizations.length === 1 ? 'Company' : 'Companies'}
</span>
</div>
{prep.organizations.map((org) => (
<PrepNoteRow key={org.path} title={org.name} subtitle="Organization" path={org.path} onOpenNote={onOpenNote} />
))}
</>
) : null}
</div>
)
}
function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) {
const [events, setEvents] = useState<UpcomingEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -487,6 +692,20 @@ function UpcomingEvents() {
return selectVisibleDays(window)
}, [events])
// The next meeting that's worth prepping for — soonest timed event with at
// least one other attendee that hasn't ended. Its row gets inline prep.
// `events` is sorted (all-day first, then by start), so `find` returns it.
const prepEventId = useMemo(() => {
const nowMs = Date.now()
const candidate = events.find((ev) => {
if (ev.isAllDay) return false
if (ev.attendees.every((a) => a.self)) return false
const endMs = ev.end ? ev.end.getTime() : ev.start.getTime() + 30 * 60 * 1000
return endMs > nowMs
})
return candidate?.id ?? null
}, [events])
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
const now = new Date()
const todayKey = localDateKey(now)
@ -532,6 +751,8 @@ function UpcomingEvents() {
key={day.dateKey}
day={day}
isToday={day.dateKey === todayKey}
prepEventId={prepEventId}
onOpenNote={onOpenNote}
/>
))}
</div>
@ -542,7 +763,7 @@ function UpcomingEvents() {
)
}
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
function UpcomingDayCard({ day, isToday, prepEventId, onOpenNote }: { day: DayGroup; isToday: boolean; prepEventId: string | null; onOpenNote: (path: string) => void }) {
const dayNum = day.date.getDate()
const month = day.date.toLocaleDateString([], { month: 'short' })
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean })
</div>
) : (
day.events.map((ev, idx) => (
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
<UpcomingEventItem
key={ev.id}
event={ev}
isLast={idx === count - 1}
isPrepTarget={ev.id === prepEventId}
onOpenNote={onOpenNote}
/>
))
)}
</div>
@ -588,14 +815,20 @@ function NowBadge() {
)
}
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
function UpcomingEventItem({ event, isLast, isPrepTarget, onOpenNote }: { event: UpcomingEvent; isLast: boolean; isPrepTarget: boolean; onOpenNote: (path: string) => void }) {
const [open, setOpen] = useState(false)
// The next meeting auto-expands its prep; any other meeting with attendees
// can be expanded on demand via the Prep toggle (resolves lazily on open).
const prepEligible = !event.isAllDay && event.attendees.some((a) => !a.self)
const [prepOpen, setPrepOpen] = useState(isPrepTarget)
const showPrep = prepEligible && prepOpen
const isNow = isEventNow(event)
const platform = meetingPlatformLabel(event.conferenceLink)
const subtitle = platform ?? event.location
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
return (
<div className={cn(!isLast && 'border-b')}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
title={titleAndLocation}
className={cn(
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
!isLast && 'border-b',
showPrep && 'border-b',
isNow ? 'bg-muted' : 'hover:bg-muted/50',
)}
>
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
</span>
) : null}
</span>
<div className="shrink-0">
<div className="flex shrink-0 items-center gap-2">
{prepEligible ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setPrepOpen((v) => !v) }}
onMouseDown={(e) => e.stopPropagation()}
aria-expanded={prepOpen}
title={prepOpen ? 'Hide prep' : 'Show meeting prep'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors',
prepOpen ? 'bg-accent text-foreground' : 'bg-background text-foreground hover:bg-accent',
)}
>
<Sparkles className="size-3.5" />
Prep
<ChevronDown className={cn('size-3 transition-transform', prepOpen && 'rotate-180')} />
</button>
) : null}
{event.conferenceLink ? (
<SplitJoinButton
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
</PopoverTrigger>
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
</Popover>
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
</div>
)
}
@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
const rows = entries
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
// Generated prep notes live under Meetings/prep/ — they're upcoming
// prep, not past meeting notes, so keep them out of this table.
.filter((entry) => !entry.path.startsWith(`${MEETINGS_ROOT}/prep/`))
.map((entry) => {
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
const parts = relative.split('/')
@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
</p>
</div>
<div className="flex-1 overflow-auto">
<UpcomingEvents />
<UpcomingEvents onOpenNote={onOpenNote} />
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-10">
@ -1058,7 +1313,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
className="block w-full min-w-0 text-left text-sm font-medium text-foreground hover:underline"
>
<span className="block truncate">{note.name}</span>
</button>

View file

@ -183,8 +183,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Preferred default models for each provider
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-6-20260202",
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
// Initialize default models from catalog

View file

@ -14,11 +14,12 @@ 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 {
open: boolean
onComplete: () => void
onComplete: (opts?: { startTour?: boolean }) => void
}
export function OnboardingModal({ open, onComplete }: 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])

View file

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

View file

@ -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&nbsp;login</code> or{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">codex&nbsp;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>
)
}

View file

@ -1,52 +1,46 @@
import { CheckCircle2 } from "lucide-react"
import { CheckCircle2, Volume2 } from "lucide-react"
import { motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { TalkingHead } from "@/components/talking-head"
import type { OnboardingState } from "../use-onboarding-state"
interface CompletionStepProps {
state: OnboardingState
}
const zeroLevel = () => 0
export function CompletionStep({ state }: CompletionStepProps) {
const { connectedProviders, gmailConnected, googleCalendarConnected, handleComplete } = state
const { connectedProviders, gmailConnected, googleCalendarConnected, handleComplete, handleCompleteWithTour } = state
const hasConnections = connectedProviders.length > 0 || gmailConnected || googleCalendarConnected
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 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.35 }}
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8"
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-6"
>
{hasConnections ? (
<>Give me 30 minutes to build your context graph. I can still help with other things on your computer.</>
@ -61,7 +55,7 @@ export function CompletionStep({ state }: CompletionStepProps) {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45 }}
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-8"
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-6"
>
<p className="text-sm font-semibold mb-3 text-left">Connected</p>
<div className="space-y-2">
@ -113,18 +107,56 @@ export function CompletionStep({ state }: CompletionStepProps) {
</motion.div>
)}
{/* CTA */}
{/* Captain's tour hand-off */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.55 }}
className="w-full max-w-sm rounded-xl border bg-muted/30 px-5 pt-4 pb-5 mb-4"
>
<div className="flex items-center gap-4">
<motion.div
initial={{ scale: 0, rotate: -12 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 18, delay: 0.65 }}
className="shrink-0"
>
<TalkingHead ttsState="idle" getLevel={zeroLevel} size={88} hat="captain" />
</motion.div>
<div className="text-left">
<p className="text-base font-semibold mb-1">Want the captain's tour?</p>
<p className="text-sm text-muted-foreground leading-snug">
A one-minute guided voyage through everything you just set up
{hasConnections ? <> perfect while I chart your data</> : null}.
</p>
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Volume2 className="size-3.5 shrink-0" />
<span>I narrate it out loud, so sound on!</span>
</p>
</div>
</div>
</motion.div>
{/* CTAs */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
transition={{ delay: 0.7 }}
className="flex w-full max-w-xs flex-col items-center gap-2"
>
<Button
onClick={handleComplete}
onClick={handleCompleteWithTour}
size="lg"
className="w-full max-w-xs h-12 text-base font-medium"
className="w-full h-12 text-base font-medium"
>
Start Using Rowboat
Take the 1-minute tour
</Button>
<Button
onClick={handleComplete}
variant="ghost"
className="text-muted-foreground"
>
Skip I'll explore myself
</Button>
</motion.div>
</div>

View file

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

View file

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

View file

@ -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
@ -20,7 +20,7 @@ export interface LlmModelOption {
release_date?: string
}
export function useOnboardingState(open: boolean, onComplete: () => void) {
export function useOnboardingState(open: boolean, onComplete: (opts?: { startTour?: boolean }) => void) {
const [currentStep, setCurrentStep] = useState<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(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)
@ -155,8 +155,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Preferred default models for each provider
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-6-20260202",
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
// Initialize default models from catalog
@ -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,50 +405,103 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
} else {
setCurrentStep(1)
}
} else if (currentStep === 3) {
setCurrentStep(2)
}
}, [currentStep, onboardingPath])
// Kept as no-arg handlers (rather than one that takes options) so the
// completion step can pass them straight to onClick without the mouse
// event leaking in as the options object.
const handleComplete = useCallback(() => {
onComplete()
}, [onComplete])
const handleTestAndSaveLlmConfig = useCallback(async () => {
if (!canTest) return
const handleCompleteWithTour = useCallback(() => {
onComplete({ startTour: true })
}, [onComplete])
// 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 typed = activeConfig.model.trim()
// Hosted providers hide the model field (it holds an auto-seeded
// default), so only treat it as user intent where the field is shown —
// mirrors showModelInput in llm-setup-step.
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
const modelInputShown = !hostedProviders.includes(llmProvider)
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
// so saving it would break chat at runtime with no obvious cause.
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
setTestState({ status: "error", error })
toast.error(error)
return false
}
const preferred = preferredDefaults[llmProvider]
// A model the user explicitly entered always wins — this used to prefer
// catalog[0], which silently replaced the user's Ollama model with
// whatever model the local server happened to list first.
const model = modelInputShown
? (typed || catalog[0] || "")
: ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "")
// `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 +687,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
showBaseURL,
isLocalProvider,
canTest,
connectedFlavors,
showMoreProviders,
setShowMoreProviders,
updateProviderConfig,
handleTestAndSaveLlmConfig,
handleTestAndAddAnother,
// OAuth state
providers,
@ -693,6 +750,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
handleNext,
handleBack,
handleComplete,
handleCompleteWithTour,
handleSwitchToRowboat,
}
}

View file

@ -0,0 +1,888 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { TalkingHead, type MascotHat } from '@/components/talking-head'
import { AgentsFleet, MascotVignette, type TourVignetteKind } from '@/components/tour-vignettes'
import { TourSounds } from '@/lib/tour-sounds'
import type { TTSState } from '@/hooks/useVoiceTTS'
import { cn } from '@/lib/utils'
import tourClipWelcome from '@/assets/tour/welcome.mp3'
import tourClipHome from '@/assets/tour/home.mp3'
import tourClipEmail from '@/assets/tour/email.mp3'
import tourClipMeetings from '@/assets/tour/meetings.mp3'
import tourClipCode from '@/assets/tour/code.mp3'
import tourClipKnowledge from '@/assets/tour/knowledge.mp3'
import tourClipAgents from '@/assets/tour/agents.mp3'
import tourClipApps from '@/assets/tour/apps.mp3'
import tourClipWorkspaces from '@/assets/tour/workspaces.mp3'
import tourClipChats from '@/assets/tour/chats.mp3'
import tourClipComposer from '@/assets/tour/composer.mp3'
import tourClipDone from '@/assets/tour/done.mp3'
export type TourNavTarget =
| 'home'
| 'email'
| 'meetings'
| 'code'
| 'knowledge'
| 'agents'
| 'apps'
| 'workspaces'
type TourStep = {
id: string
/** Matches a [data-tour-id] element. Steps whose target is absent are skipped. */
targetId?: string
/** View to open when the step starts, via App's navigation handlers. */
navigate?: TourNavTarget
/** Costume the mascot wears at this stop. */
hat?: MascotHat
/** Looping animation staged around the mascot while it presents this stop. */
vignette?: TourVignetteKind
title: string
text: string
/** Spoken narration, when it should differ from the bubble text. */
voiceText?: string
}
const TOUR_STEPS: TourStep[] = [
{
id: 'welcome',
title: 'All aboard! ⚓',
text: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time. Use Next or your arrow keys.",
voiceText: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time.",
},
{
id: 'home',
targetId: 'nav-home',
navigate: 'home',
title: 'First stop: Home',
text: 'Home is your landing spot — a quick overview of what needs your attention to get you back into the flow.',
},
{
id: 'email',
targetId: 'nav-email',
navigate: 'email',
hat: 'mailcap',
vignette: 'email',
title: 'Email',
text: 'Read and triage your inbox right here. Rowboat can summarize threads, label messages, and help you draft replies.',
},
{
id: 'meetings',
targetId: 'nav-meetings',
navigate: 'meetings',
hat: 'headphones',
vignette: 'meetings',
title: 'Meetings',
text: 'Record or join meetings, and get transcripts and notes automatically — prep briefs show up before your calls, too.',
},
{
id: 'code',
targetId: 'nav-code',
navigate: 'code',
hat: 'hardhat',
title: 'Code',
text: 'The Code section runs coding agents on your projects — point one at a folder and drive it from a chat.',
},
{
id: 'knowledge',
targetId: 'nav-knowledge',
navigate: 'knowledge',
hat: 'gradcap',
vignette: 'brain',
title: 'Brain',
text: "Brain is your knowledge base — notes, files, and everything Rowboat learns for you, all connected and searchable.",
},
{
id: 'agents',
targetId: 'nav-agents',
navigate: 'agents',
hat: 'captain',
vignette: 'agents',
title: 'Background agents',
text: 'Background agents work on schedules — they keep your Brain fresh and take care of recurring tasks while you row elsewhere.',
},
{
id: 'apps',
targetId: 'nav-apps',
navigate: 'apps',
title: 'Apps',
text: 'Apps are mini-apps you build right here in Rowboat — they get the same tools and integrations I do, and you can share them with other people. Just ask for one in chat.',
},
{
id: 'workspaces',
targetId: 'nav-workspaces',
navigate: 'workspaces',
hat: 'explorer',
title: 'Workspaces',
text: 'Workspaces hold your project folders and files, so related work stays docked together.',
},
{
id: 'chats',
targetId: 'nav-chats',
title: 'Chats',
text: 'Your recent conversations live here — pick any of them back up right where you left off.',
},
{
id: 'composer',
targetId: 'chat-composer',
title: 'Talk to Rowboat',
text: 'And this is where we talk! Type, dictate with the mic, or turn on voice output — tap my face button and Ill read replies out loud myself.',
},
{
id: 'done',
hat: 'party',
title: "Land ho! 🎉",
text: "That's the whole bay — and there's my wake to prove it. Take this voyage again anytime from the bottom of the sidebar. Happy rowing!",
},
]
// Pre-recorded narration bundled with the app (no TTS API call, works
// offline/signed-out). Regenerate with scripts/generate-tour-audio.mjs after
// editing any step's text. Steps without a clip fall back to live TTS.
const TOUR_CLIPS: Record<string, string> = {
welcome: tourClipWelcome,
home: tourClipHome,
email: tourClipEmail,
meetings: tourClipMeetings,
code: tourClipCode,
knowledge: tourClipKnowledge,
agents: tourClipAgents,
apps: tourClipApps,
workspaces: tourClipWorkspaces,
chats: tourClipChats,
composer: tourClipComposer,
done: tourClipDone,
}
const MASCOT_SIZE = 120
const VIEWPORT_MARGIN = 16
const BUBBLE_WIDTH = 288
const TARGET_RESOLVE_TIMEOUT_MS = 1500
const ZOOM_SCALE = 1.05
const GLIDE_EASING = 'cubic-bezier(0.45, 0, 0.2, 1)'
type Pt = { x: number; y: number }
type Rect = { left: number; top: number; width: number; height: number }
type Spot = Rect & { round: boolean }
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v))
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
function quadPoint(p0: Pt, c: Pt, p1: Pt, t: number): Pt {
const u = 1 - t
return {
x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x,
y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y,
}
}
function quadPathLength(d: string): number {
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('d', d)
return p.getTotalLength()
}
// A data-tour-id can legitimately appear on several elements (e.g. the chat
// composer renders in both the full-screen chat and the side pane) — pick the
// one that is actually laid out.
function findTourTarget(targetId: string): HTMLElement | null {
const nodes = document.querySelectorAll<HTMLElement>(`[data-tour-id="${targetId}"]`)
for (const el of nodes) {
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) return el
}
return null
}
function mascotDestForCenter(): Pt {
return {
x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2,
y: window.innerHeight / 2 - MASCOT_SIZE / 2,
}
}
function mascotDestForRect(rect: Rect): Pt {
let x: number
let y: number
const fitsRight = rect.left + rect.width + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth
if (fitsRight) {
x = rect.left + rect.width + 20
y = rect.top + rect.height / 2 - MASCOT_SIZE / 2
} else if (rect.top > MASCOT_SIZE + VIEWPORT_MARGIN * 2) {
x = rect.left + rect.width / 2 - MASCOT_SIZE / 2
y = rect.top - MASCOT_SIZE - 20
} else {
x = rect.left - MASCOT_SIZE - 20
y = rect.top + rect.height / 2 - MASCOT_SIZE / 2
}
return {
x: clamp(x, VIEWPORT_MARGIN, window.innerWidth - MASCOT_SIZE - VIEWPORT_MARGIN),
y: clamp(y, VIEWPORT_MARGIN, window.innerHeight - MASCOT_SIZE - VIEWPORT_MARGIN),
}
}
function spotForRect(rect: Rect): Spot {
return {
left: rect.left - 6,
top: rect.top - 6,
width: rect.width + 12,
height: rect.height + 12,
round: false,
}
}
function spotForMascot(dest: Pt): Spot {
return {
left: dest.x - 26,
top: dest.y - 18,
width: MASCOT_SIZE + 52,
height: MASCOT_SIZE + 44,
round: true,
}
}
type ProductTourProps = {
onClose: () => void
onNavigate: (target: TourNavTarget) => void
ttsAvailable: boolean
ttsState: TTSState
speak: (text: string) => void
speakUrl: (url: string) => void
cancelSpeech: () => void
getLevel: () => number
}
/**
* The Grand Voyage: a mascot-guided walkthrough where the app dims to a
* night-time bay, the boat rows curved routes between [data-tour-id] anchors
* (leaving a dotted wake behind it), a spotlight and gentle camera zoom reveal
* each section, and a parchment mini-map charts progress. Narrated with TTS
* lip sync when available; ends in confetti.
*
* Rendered through a portal to <body> so the camera zoom applied to the app
* shell never transforms the tour's own fixed-position layers.
*/
export function ProductTour({
onClose,
onNavigate,
ttsAvailable,
ttsState,
speak,
speakUrl,
cancelSpeech,
getLevel,
}: ProductTourProps) {
const [stepIndex, setStepIndex] = useState(0)
const [arrived, setArrived] = useState(false)
const [rowing, setRowing] = useState(false)
const [flipped, setFlipped] = useState(false)
const [bubbleSide, setBubbleSide] = useState<'left' | 'right'>('right')
const [spot, setSpot] = useState<Spot | null>(null)
const [wakes, setWakes] = useState<{ id: number; d: string }[]>([])
const [activeWake, setActiveWake] = useState<{ d: string; len: number } | null>(null)
const [confettiOn, setConfettiOn] = useState(false)
const [resizeNonce, setResizeNonce] = useState(0)
const reducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[]
)
// Mascot position is animated by mutating the container's transform directly
// (60fps travel without re-rendering React); posRef is the source of truth.
const mascotElRef = useRef<HTMLDivElement>(null)
const posRef = useRef<Pt>({
x: window.innerWidth / 2 - MASCOT_SIZE / 2,
y: window.innerHeight + 60,
})
const travelRafRef = useRef(0)
const wakePathElRef = useRef<SVGPathElement>(null)
const wakeIdRef = useRef(0)
const curveSideRef = useRef(1)
const lastSplashRef = useRef(0)
const directionRef = useRef(1)
const enteredStepRef = useRef(-1)
const stepIndexRef = useRef(stepIndex)
// Camera zoom state applied to the app shell (outside the portal)
const shellRef = useRef<HTMLElement | null>(null)
const zoomRef = useRef<{ ox: number; oy: number; s: number } | null>(null)
const soundsRef = useRef<TourSounds | null>(null)
const onCloseRef = useRef(onClose)
const onNavigateRef = useRef(onNavigate)
const speakRef = useRef(speak)
const speakUrlRef = useRef(speakUrl)
const cancelSpeechRef = useRef(cancelSpeech)
const ttsAvailableRef = useRef(ttsAvailable)
// Keep latest callbacks/state in refs so the step effect and key handlers
// stay stable. Runs before the step effect below (effect order = call order).
useEffect(() => {
stepIndexRef.current = stepIndex
onCloseRef.current = onClose
onNavigateRef.current = onNavigate
speakRef.current = speak
speakUrlRef.current = speakUrl
cancelSpeechRef.current = cancelSpeech
ttsAvailableRef.current = ttsAvailable
})
useLayoutEffect(() => {
if (mascotElRef.current) {
mascotElRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)`
}
soundsRef.current = new TourSounds()
return () => {
soundsRef.current?.dispose()
soundsRef.current = null
}
}, [])
// Grab the app shell for the camera zoom; restore it when the tour ends
useEffect(() => {
const shell = document.querySelector<HTMLElement>('.rowboat-shell')
shellRef.current = shell
return () => {
if (shell) {
shell.style.transform = ''
shell.style.transformOrigin = ''
shell.style.transition = ''
}
shellRef.current = null
zoomRef.current = null
}
}, [])
const applyZoom = useCallback((origin: { ox: number; oy: number } | null) => {
const shell = shellRef.current
if (!shell || reducedMotion) return
if (origin) {
// transform-origin transitions too, so moving between targets pans
// smoothly instead of jumping when the origin changes
shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}`
shell.style.transformOrigin = `${origin.ox}px ${origin.oy}px`
shell.style.transform = `scale(${ZOOM_SCALE})`
zoomRef.current = { ox: origin.ox, oy: origin.oy, s: ZOOM_SCALE }
} else {
shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}`
shell.style.transform = 'scale(1)'
zoomRef.current = null
}
}, [reducedMotion])
// Where the element will sit on screen once this step's zoom settles:
// undo the current zoom mathematically, then apply the upcoming one (whose
// origin is the target's own center, so the center never moves).
const displayedRect = useCallback((el: HTMLElement, willZoom: boolean): { rect: Rect; origin: { ox: number; oy: number } } => {
const m = el.getBoundingClientRect()
const z = zoomRef.current
let cx = m.left + m.width / 2
let cy = m.top + m.height / 2
let w = m.width
let h = m.height
if (z) {
cx = z.ox + (cx - z.ox) / z.s
cy = z.oy + (cy - z.oy) / z.s
w /= z.s
h /= z.s
}
const s = willZoom && !reducedMotion ? ZOOM_SCALE : 1
return {
rect: { left: cx - (w * s) / 2, top: cy - (h * s) / 2, width: w * s, height: h * s },
origin: { ox: cx, oy: cy },
}
}, [reducedMotion])
const cancelTravel = useCallback(() => {
cancelAnimationFrame(travelRafRef.current)
}, [])
const moveMascot = useCallback((p: Pt) => {
posRef.current = p
if (mascotElRef.current) {
mascotElRef.current.style.transform = `translate(${p.x}px, ${p.y}px)`
}
}, [])
// Row along a curved path from the current position, drawing the wake as we
// go and splashing the oar; commits the wake as a dotted trail on arrival.
const startTravel = useCallback((dest: Pt, onArrive: () => void) => {
cancelTravel()
const from = { ...posRef.current }
const dist = Math.hypot(dest.x - from.x, dest.y - from.y)
if (dist < 6 || reducedMotion) {
moveMascot(dest)
setRowing(false)
onArrive()
return
}
const dur = clamp(dist * 1.1, 550, 1500)
curveSideRef.current = -curveSideRef.current
const mx = (from.x + dest.x) / 2
const my = (from.y + dest.y) / 2
const nx = -(dest.y - from.y) / dist
const ny = (dest.x - from.x) / dist
const mag = Math.min(140, dist * 0.3) * curveSideRef.current
const c = { x: mx + nx * mag, y: my + ny * mag }
// Wake follows the stern (bottom-center of the mascot box)
const sternX = MASCOT_SIZE / 2
const sternY = MASCOT_SIZE * 0.82
const d = `M ${from.x + sternX} ${from.y + sternY} Q ${c.x + sternX} ${c.y + sternY} ${dest.x + sternX} ${dest.y + sternY}`
const len = quadPathLength(d)
setActiveWake({ d, len })
setFlipped(dest.x < from.x - 4)
setRowing(true)
lastSplashRef.current = 0
const t0 = performance.now()
const frame = (now: number) => {
const raw = Math.min(1, (now - t0) / dur)
const t = easeInOutCubic(raw)
moveMascot(quadPoint(from, c, dest, t))
if (wakePathElRef.current) {
wakePathElRef.current.style.strokeDashoffset = String(len * (1 - t))
}
if (now - lastSplashRef.current > 420) {
lastSplashRef.current = now
soundsRef.current?.splash()
}
if (raw < 1) {
travelRafRef.current = requestAnimationFrame(frame)
} else {
setRowing(false)
setActiveWake(null)
setWakes((ws) => [...ws, { id: wakeIdRef.current++, d }])
onArrive()
}
}
travelRafRef.current = requestAnimationFrame(frame)
}, [cancelTravel, moveMascot, reducedMotion])
const playDing = useCallback(() => soundsRef.current?.ding(), [])
const finish = useCallback(() => {
cancelSpeechRef.current()
onCloseRef.current()
}, [])
const goTo = useCallback((index: number, direction: 1 | -1) => {
directionRef.current = direction
if (index < 0) return
if (index >= TOUR_STEPS.length) {
finish()
return
}
// Silence the current step's narration right away — not on arrival —
// so it can't talk over (or into) the next step's speech.
cancelSpeechRef.current()
setStepIndex(index)
}, [finish])
// Stop any in-flight narration when the tour unmounts
useEffect(() => () => cancelSpeechRef.current(), [])
useEffect(() => {
const handleResize = () => setResizeNonce((n) => n + 1)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// Enter the current step: navigate, wait for its anchor, aim the spotlight
// and camera, row over, then narrate. Re-runs on resize to re-anchor.
useEffect(() => {
const step = TOUR_STEPS[stepIndex]
const entering = enteredStepRef.current !== stepIndex
let cancelled = false
if (entering && step.navigate) {
onNavigateRef.current(step.navigate)
}
const settle = (dest: Pt, side: 'left' | 'right', spotlight: Spot, origin: { ox: number; oy: number } | null) => {
applyZoom(origin)
setSpot(spotlight)
setBubbleSide(side)
if (!entering) {
// Resize while already at this step: jump, keep the bubble up
cancelTravel()
moveMascot(dest)
return
}
enteredStepRef.current = stepIndex
setArrived(false)
startTravel(dest, () => {
if (cancelled) return
setArrived(true)
soundsRef.current?.bump()
cancelSpeechRef.current()
const clip = TOUR_CLIPS[step.id]
if (clip) {
speakUrlRef.current(clip)
} else if (ttsAvailableRef.current) {
speakRef.current(step.voiceText ?? step.text)
}
if (stepIndex === TOUR_STEPS.length - 1) {
setConfettiOn(true)
soundsRef.current?.fanfare()
}
})
}
if (!step.targetId) {
const dest = mascotDestForCenter()
applyZoom(null)
settle(dest, 'right', spotForMascot(dest), null)
return () => {
cancelled = true
cancelTravel()
}
}
const startedAt = performance.now()
let pollRaf = 0
const attempt = () => {
if (cancelled) return
const el = findTourTarget(step.targetId!)
if (el) {
const { rect, origin } = displayedRect(el, true)
const dest = mascotDestForRect(rect)
const side: 'left' | 'right' = dest.x + MASCOT_SIZE / 2 < window.innerWidth / 2 ? 'right' : 'left'
settle(dest, side, spotForRect(rect), origin)
return
}
if (performance.now() - startedAt < TARGET_RESOLVE_TIMEOUT_MS) {
pollRaf = requestAnimationFrame(attempt)
} else if (entering) {
// Anchor never appeared (feature disabled / pane closed) — skip past it
goTo(stepIndex + directionRef.current, directionRef.current as 1 | -1)
}
}
attempt()
return () => {
cancelled = true
cancelAnimationFrame(pollRaf)
cancelTravel()
}
}, [stepIndex, resizeNonce, goTo, applyZoom, displayedRect, startTravel, cancelTravel, moveMascot])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
finish()
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
e.preventDefault()
goTo(stepIndexRef.current + 1, 1)
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
goTo(stepIndexRef.current - 1, -1)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [finish, goTo])
const step = TOUR_STEPS[stepIndex]
const isFirst = stepIndex === 0
const isLast = stepIndex === TOUR_STEPS.length - 1
return createPortal(
<>
<style>{`
@keyframes tour-bubble-in {
0% { opacity: 0; transform: translateY(6px) scale(0.97); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes tour-wave-drift {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes tour-stamp-in {
0% { opacity: 0; transform: scale(2); }
100% { opacity: 1; transform: scale(1); }
}
`}</style>
{/* night falls: dim everything except the spotlight cutout */}
{spot && (
<div
className="pointer-events-none fixed z-[64]"
style={{
left: spot.left,
top: spot.top,
width: spot.width,
height: spot.height,
borderRadius: spot.round ? 9999 : 14,
boxShadow: '0 0 0 200vmax rgba(7, 14, 26, 0.52)',
transition: `all 0.9s ${GLIDE_EASING}`,
}}
/>
)}
<TourWater />
{/* wake trails: the committed dotted route + the wake being drawn now */}
<svg className="pointer-events-none fixed inset-0 z-[66] h-full w-full">
{wakes.map((w) => (
<path
key={w.id}
d={w.d}
fill="none"
stroke="#9CCBEA"
strokeWidth={4}
strokeLinecap="round"
strokeDasharray="1 11"
opacity={0.75}
/>
))}
{activeWake && (
<path
ref={wakePathElRef}
d={activeWake.d}
fill="none"
stroke="#BFE0F5"
strokeWidth={5}
strokeLinecap="round"
strokeDasharray={activeWake.len}
strokeDashoffset={activeWake.len}
opacity={0.8}
/>
)}
</svg>
<TourMiniMap total={TOUR_STEPS.length} current={stepIndex} arrived={arrived} />
{/* the boat (position driven imperatively during travel) */}
<div
ref={mascotElRef}
className="fixed left-0 top-0 z-[70]"
style={{ width: MASCOT_SIZE, pointerEvents: 'none' }}
>
{arrived && !reducedMotion && step.vignette && step.vignette !== 'agents' && (
<MascotVignette kind={step.vignette} playDing={playDing} />
)}
<div style={{ transform: flipped ? 'scaleX(-1)' : undefined, transition: 'transform 0.35s ease-in-out' }}>
<TalkingHead ttsState={ttsState} getLevel={getLevel} size={MASCOT_SIZE} hat={step.hat} rowing={rowing} />
</div>
{arrived && (
<div
key={step.id}
className={cn(
'pointer-events-auto absolute top-0 z-10 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg',
bubbleSide === 'right' ? 'left-full ml-3' : 'right-full mr-3'
)}
style={{
width: BUBBLE_WIDTH,
animation: 'tour-bubble-in 0.25s ease-out',
}}
>
<button
type="button"
onClick={finish}
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="End tour"
>
<X className="h-3.5 w-3.5" />
</button>
<p className="pr-6 text-sm font-semibold">{step.title}</p>
<p className="mt-1.5 text-sm text-muted-foreground">{step.text}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs tabular-nums text-muted-foreground">
{stepIndex + 1} / {TOUR_STEPS.length}
</span>
<div className="flex items-center gap-1.5">
{!isFirst && (
<Button variant="ghost" size="sm" className="h-7 px-2.5" onClick={() => goTo(stepIndex - 1, -1)}>
Back
</Button>
)}
<Button
size="sm"
className="h-7 px-3"
onClick={() => (isLast ? finish() : goTo(stepIndex + 1, 1))}
>
{isLast ? 'Done' : 'Next'}
</Button>
</div>
</div>
</div>
)}
</div>
{arrived && !reducedMotion && step.vignette === 'agents' && <AgentsFleet />}
{confettiOn && !reducedMotion && <ConfettiBurst />}
</>,
document.body
)
}
/** Animated translucent waves lapping at the bottom of the screen. */
function TourWater() {
const back = useMemo(() => wavePath(2400, 96, 8, 22), [])
const front = useMemo(() => wavePath(2400, 96, 12, 30), [])
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[65] h-24 overflow-hidden" aria-hidden="true">
<svg
className="absolute bottom-0 left-0 h-full"
style={{ width: '200%', animation: 'tour-wave-drift 11s linear infinite' }}
viewBox="0 0 2400 96"
preserveAspectRatio="none"
>
<path d={back} fill="#5F9BC9" opacity={0.3} />
</svg>
<svg
className="absolute bottom-0 left-0 h-full"
style={{ width: '200%', animation: 'tour-wave-drift 7s linear infinite reverse' }}
viewBox="0 0 2400 96"
preserveAspectRatio="none"
>
<path d={front} fill="#8FB6D9" opacity={0.35} />
</svg>
</div>
)
}
// Periodic wave: alternating up/down humps so a -50% translate loops seamlessly
// (both hump counts are even, so half the width is a whole number of periods).
function wavePath(width: number, height: number, humps: number, amp: number): string {
const yTop = 34
const seg = width / humps
let d = `M 0 ${yTop}`
for (let i = 0; i < humps; i++) {
d += ` q ${seg / 2} ${i % 2 === 0 ? -amp : amp} ${seg} 0`
}
d += ` L ${width} ${height} L 0 ${height} Z`
return d
}
/** Parchment chart in the corner: islands per stop, dotted route, boat marker. */
function TourMiniMap({ total, current, arrived }: { total: number; current: number; arrived: boolean }) {
const MW = 184
const MH = 96
const points = useMemo(
() =>
Array.from({ length: total }, (_, i) => {
const t = total === 1 ? 0 : i / (total - 1)
return {
x: 14 + (MW - 28) * t,
y: MH / 2 + 4 + Math.sin(t * Math.PI * 1.5 + 0.6) * (MH * 0.26),
}
}),
[total]
)
const route = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ')
const boat = points[clamp(current, 0, total - 1)]
return (
<div
className="fixed bottom-5 left-5 z-[71] rounded-lg border-2 border-[#8A6B3D]/60 bg-[#F4E9CE] px-2 pb-1.5 pt-2 shadow-xl"
style={{ transform: 'rotate(-1.2deg)' }}
aria-hidden="true"
>
<p className="mb-0.5 px-1 text-[9px] font-semibold uppercase tracking-[0.22em] text-[#6B5138]">
Voyage chart
</p>
<svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`}>
<path d={route} fill="none" stroke="#8A6B3D" strokeWidth={1.5} strokeDasharray="3 4" opacity={0.65} />
{points.map((p, i) => {
const visited = i < current || (i === current && arrived)
return (
<g key={i}>
<ellipse cx={p.x} cy={p.y} rx={7} ry={5} fill="#DFC896" stroke="#8A6B3D" strokeWidth={1.5} />
{visited && (
<g style={{ animation: 'tour-stamp-in 0.3s ease-out backwards' }}>
<line x1={p.x - 1} y1={p.y - 13} x2={p.x - 1} y2={p.y - 3} stroke="#7A4A21" strokeWidth={1.5} />
<path d={`M ${p.x - 1} ${p.y - 13} L ${p.x + 6} ${p.y - 10.5} L ${p.x - 1} ${p.y - 8} Z`} fill="#D9534F" />
</g>
)}
</g>
)
})}
{/* boat marker glides between islands in step with the real mascot */}
<g style={{ transform: `translate(${boat.x}px, ${boat.y - 7}px)`, transition: `transform 0.9s ${GLIDE_EASING}` }}>
<path d="M -7 0 Q 0 4 7 0 Q 4 6 0 6 Q -4 6 -7 0 Z" fill="#54402F" stroke="#3E2E24" strokeWidth={1} />
<circle cx={0} cy={-3} r={3} fill="#E8E9F5" stroke="#17171B" strokeWidth={1} />
</g>
</svg>
</div>
)
}
/** Two confetti cannons firing from the bottom corners, canvas-driven. */
function ConfettiBurst() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
const w = window.innerWidth
const h = window.innerHeight
const dpr = window.devicePixelRatio || 1
canvas.width = w * dpr
canvas.height = h * dpr
ctx.scale(dpr, dpr)
const colors = ['#5B8DEF', '#F2B8BE', '#FFD166', '#7AC74F', '#8FB6D9', '#F2699C']
const parts = Array.from({ length: 150 }, (_, i) => {
const fromLeft = i % 2 === 0
return {
x: fromLeft ? 24 : w - 24,
y: h - 40,
vx: (fromLeft ? 1 : -1) * (2.5 + Math.random() * 6.5),
vy: -(9 + Math.random() * 8),
size: 5 + Math.random() * 5,
color: colors[i % colors.length],
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
}
})
let raf = 0
const t0 = performance.now()
const frame = (now: number) => {
ctx.clearRect(0, 0, w, h)
for (const p of parts) {
p.vy += 0.18
p.vx *= 0.99
p.x += p.vx
p.y += p.vy
p.rot += p.vr
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(p.rot)
ctx.fillStyle = p.color
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.66)
ctx.restore()
}
if (now - t0 < 3200) {
raf = requestAnimationFrame(frame)
} else {
ctx.clearRect(0, 0, w, h)
}
}
raf = requestAnimationFrame(frame)
return () => cancelAnimationFrame(raf)
}, [])
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[72]"
style={{ width: '100vw', height: '100vh' }}
/>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } 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, Smartphone } from "lucide-react"
import {
Dialog,
@ -25,9 +25,11 @@ 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 { MobileChannelsSettings } from "@/components/settings/mobile-channels-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" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -50,6 +52,12 @@ const tabs: TabConfig[] = [
icon: Plug,
description: "Manage accounts and tools",
},
{
id: "mobile",
label: "Mobile",
icon: Smartphone,
description: "Chat with Rowboat from WhatsApp or Telegram",
},
{
id: "models",
label: "Models",
@ -83,6 +91,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",
@ -313,8 +327,8 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
]
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-6-20260202",
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
@ -332,7 +346,7 @@ type ProviderModelConfig = {
autoPermissionDecisionModel: string
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
@ -350,6 +364,11 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
// "Defer background tasks while a chat is running" — a top-level
// models.json flag. deferExplicit tracks whether the user (or the Ollama
// auto-enable) has ever set it, so we only auto-enable once.
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
const [deferExplicit, setDeferExplicit] = useState(false)
const activeConfig = providerConfigs[provider]
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
@ -378,43 +397,13 @@ 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(() => {
if (!dialogOpen) return
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
async function loadCurrentConfig() {
try {
setConfigLoading(true)
@ -422,6 +411,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
path: "config/models.json",
})
const parsed = JSON.parse(result.data)
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor)
@ -440,10 +431,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: e.apiKey || "",
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "",
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
knowledgeGraphModel: asString(e.knowledgeGraphModel),
meetingNotesModel: asString(e.meetingNotesModel),
liveNoteAgentModel: asString(e.liveNoteAgentModel),
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
};
}
}
@ -459,10 +450,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
knowledgeGraphModel: asString(parsed.knowledgeGraphModel),
meetingNotesModel: asString(parsed.meetingNotesModel),
liveNoteAgentModel: asString(parsed.liveNoteAgentModel),
autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel),
};
}
return next;
@ -478,6 +469,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
loadCurrentConfig()
}, [dialogOpen])
const handleDeferToggle = useCallback(async (value: boolean) => {
setDeferBackgroundTasks(value)
setDeferExplicit(true)
try {
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save setting")
}
}, [])
// Load models catalog
useEffect(() => {
if (!dialogOpen) return
@ -535,10 +537,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
},
model: allModels[0] || "",
models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
...(rowboatConnected ? {} : {
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) {
@ -546,7 +550,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
setDefaultProvider(provider)
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Model configuration saved")
// Local models compete with background agents for the same hardware:
// when the user connects Ollama and has never touched the defer
// flag, enable it for them (they can switch it off below).
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
void handleDeferToggle(true)
}
// Capability probe caveats (local models): saved, but the user should
// know when the model can't do tools or has a too-small context.
const warnings: string[] = result.warnings ?? []
if (warnings.length > 0) {
for (const warning of warnings) {
toast.warning(warning, { duration: 12000 })
}
toast.success("Model configuration saved (with warnings)")
} else {
toast.success("Model configuration saved")
}
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
@ -555,7 +575,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, provider, activeConfig])
}, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
@ -570,10 +590,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
},
model: allModels[0],
models: allModels,
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
...(rowboatConnected ? {} : {
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'))
@ -581,7 +603,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} catch {
toast.error("Failed to set default provider")
}
}, [providerConfigs])
}, [providerConfigs, rowboatConnected])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
try {
@ -602,10 +624,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}
parsed.model = defModels[0] || ""
parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
if (!rowboatConnected) {
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",
@ -621,7 +645,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, providerConfigs])
}, [defaultProvider, providerConfigs, rowboatConnected])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
const isDefault = defaultProvider === p.id
@ -643,7 +667,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{p.name}</span>
{isDefault && (
{isDefault && !rowboatConnected && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
@ -652,16 +676,18 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
<div className="mt-1.5 flex items-center gap-3">
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
{!rowboatConnected && (
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
)}
<span
role="button"
onClick={(e) => {
@ -713,7 +739,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="grid grid-cols-2 gap-3">
{/* Assistant models (left column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
@ -721,48 +747,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 && (
@ -770,6 +777,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)}
</div>
{!rowboatConnected && (<>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
@ -905,6 +913,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</Select>
)}
</div>
</>)}
</div>
{/* API Key */}
@ -953,6 +962,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</div>
)}
{/* Defer background tasks while chatting */}
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Defer background tasks while chatting</div>
<div className="text-xs text-muted-foreground mt-0.5">
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
</div>
</div>
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
</div>
{/* Test & Save button */}
<Button
onClick={handleTestAndSave}
@ -1289,11 +1309,45 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
}
// --- Rowboat Model Settings (when signed in via Rowboat) ---
//
// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from
// BYOK providers configured below. Values are provider-qualified
// ("provider::model") and saved via models:updateConfig as {provider, model}
// refs, so a signed-in user can e.g. keep the gateway assistant while
// running background agents on a local Ollama model.
interface HybridModelOption {
provider: string
model: string
label: string
}
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
}
const HYBRID_SEP = "::"
const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}`
function parseHybridKey(key: string): { provider: string; model: string } | null {
const index = key.indexOf(HYBRID_SEP)
if (index <= 0) return null
return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) }
}
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [gatewayModels, setGatewayModels] = useState<LlmModelOption[]>([])
const [selectedModel, setSelectedModel] = useState("")
const [selectedKgModel, setSelectedKgModel] = useState("")
const [options, setOptions] = useState<HybridModelOption[]>([])
const [selectedDefault, setSelectedDefault] = useState("")
const [selectedKg, setSelectedKg] = useState("")
const [selectedLiveNote, setSelectedLiveNote] = useState("")
const [selectedAutoPermission, setSelectedAutoPermission] = useState("")
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -1303,22 +1357,63 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
async function load() {
setLoading(true)
try {
// Fetch gateway models
const listResult = await window.ipc.invoke("models:list", null)
const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat")
const models = rowboatProvider?.models || []
setGatewayModels(models)
const collected: HybridModelOption[] = []
const seen = new Set<string>()
const push = (provider: string, model: string, label?: string) => {
if (!model) return
const key = hybridKey(provider, model)
if (seen.has(key)) return
seen.add(key)
collected.push({ provider, model, label: label || model })
}
// Read current selection from config
const catalog: Record<string, LlmModelOption[]> = {}
try {
const listResult = await window.ipc.invoke("models:list", null)
for (const p of listResult.providers || []) {
catalog[p.id] = p.models || []
}
} catch { /* offline — BYOK entries below still load */ }
for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id)
let parsed: Record<string, unknown> = {}
try {
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(configResult.data)
if (parsed?.model) setSelectedModel(parsed.model)
if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel)
} catch {
// No config yet — pick first model as default
if (models.length > 0) setSelectedModel(models[0].id)
parsed = JSON.parse(configResult.data)
} catch { /* no BYOK config yet */ }
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
for (const [flavor, entry] of Object.entries(providersMap)) {
const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0
const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0
if (!hasKey && !hasBaseURL) continue
push(flavor, typeof entry.model === "string" ? entry.model : "")
const catalogModels = catalog[flavor] || []
if (catalogModels.length > 0) {
for (const m of catalogModels) push(flavor, m.id, m.name || m.id)
} else {
for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m)
}
}
setOptions(collected)
// Current selections. Legacy string overrides pair with the BYOK
// top-level flavor (mirrors core/models/defaults.ts).
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
const toKey = (value: unknown): string => {
if (!value) return ""
if (typeof value === "string") {
return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : ""
}
const ref = value as { provider?: unknown; model?: unknown }
return typeof ref.provider === "string" && typeof ref.model === "string"
? hybridKey(ref.provider, ref.model)
: ""
}
setSelectedDefault(toKey(parsed.defaultSelection))
setSelectedKg(toKey(parsed.knowledgeGraphModel))
setSelectedLiveNote(toKey(parsed.liveNoteAgentModel))
setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel))
} catch {
toast.error("Failed to load models")
} finally {
@ -1330,13 +1425,14 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}, [dialogOpen])
const handleSave = useCallback(async () => {
if (!selectedModel) return
setSaving(true)
try {
await window.ipc.invoke("models:saveConfig", {
provider: { flavor: "openrouter" as const },
model: selectedModel,
knowledgeGraphModel: selectedKgModel || undefined,
const toRef = (key: string) => (key ? parseHybridKey(key) : null)
await window.ipc.invoke("models:updateConfig", {
defaultSelection: toRef(selectedDefault),
knowledgeGraphModel: toRef(selectedKg),
liveNoteAgentModel: toRef(selectedLiveNote),
autoPermissionDecisionModel: toRef(selectedAutoPermission),
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Model configuration saved")
@ -1345,7 +1441,37 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} finally {
setSaving(false)
}
}, [selectedModel, selectedKgModel])
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
const renderSelect = (
label: string,
value: string,
onChange: (v: string) => void,
defaultLabel: string,
) => (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">{defaultLabel}</SelectItem>
{options.map((o) => {
const key = hybridKey(o.provider, o.model)
return (
<SelectItem key={key} value={key}>
{o.label}
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[o.provider] || o.provider}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
if (loading) {
return (
@ -1358,46 +1484,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Select the models Rowboat uses. These are provided through your Rowboat account.
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
</p>
{/* Assistant model */}
<div className="space-y-2">
<label className="text-sm font-medium">Assistant model</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Knowledge graph model */}
<div className="space-y-2">
<label className="text-sm font-medium">Knowledge graph model</label>
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Same as assistant" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")}
{renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")}
{renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")}
{renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")}
{/* Save */}
<Button onClick={handleSave} disabled={!selectedModel || saving}>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
@ -1750,55 +1846,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 &amp; sign in
</a>
)}
</div>
)
@ -1901,6 +2007,14 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
</p>
<p>
For each agent you want to use, you must have it{' '}
<strong className="text-foreground">installed and logged in</strong> on this machine: click{' '}
<strong className="text-foreground">Enable</strong> below to download its engine, and sign in by
running <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">claude login</code>{' '}
or <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">codex login</code>{' '}
in your terminal. Code mode uses that saved login.
</p>
</div>
<div className="space-y-2">
@ -1918,15 +2032,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>
@ -1978,8 +2094,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-amber-900 dark:text-amber-200">
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
account, then click Re-check.
Neither Claude Code nor Codex is ready. Click Enable above to download an engine, sign in with a
subscription account, then click Re-check.
</div>
</div>
)}
@ -1987,6 +2103,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) {
@ -2020,7 +2234,9 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
})
}, [open])
const visibleTabs = useMemo(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected])
// Hybrid mode: the Models tab is shown in both modes — signed-in users can
// pick gateway models AND bring their own providers/models alongside.
const visibleTabs = tabs
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
const isJsonTab = activeTab === "mcp" || activeTab === "security"
@ -2034,7 +2250,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)
@ -2142,7 +2358,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 === "mobile" || 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" ? (
@ -2157,14 +2373,30 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
</div>
</div>
) : activeTab === "mobile" ? (
<MobileChannelsSettings dialogOpen={open} />
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
? (
<div className="space-y-8">
<RowboatModelSettings dialogOpen={open} />
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Your own providers</h4>
<p className="text-xs text-muted-foreground">
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
</p>
<ModelSettings dialogOpen={open} rowboatConnected />
</div>
</div>
)
: <ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "notifications" ? (
<NotificationSettings dialogOpen={open} />
) : activeTab === "help" ? (
<HelpSettings />
) : activeTab === "code-mode" ? (

View file

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

View file

@ -1,19 +1,37 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { useConnectors } from "@/hooks/useConnectors"
import { useConnectors, actionableSlackError } from "@/hooks/useConnectors"
interface ConnectedAccountsSettingsProps {
dialogOpen: boolean
}
function relativeTime(iso?: string): string {
if (!iso) return "never"
const then = Date.parse(iso)
if (!Number.isFinite(then)) return "never"
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return "just now"
const diffMin = Math.round(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return `${Math.round(diffHr / 24)}d ago`
}
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
const c = useConnectors(dialogOpen)
// Windows exclusively locks Slack's Cookies DB while it runs, so we offer a
// "quit Slack first" one-click import there. mac/Linux import with Slack open.
const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = c.providerStates[provider] || {
@ -237,6 +255,224 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
{/* Team Communication Section */}
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-emerald-600 truncate">
{c.slackWorkspaces.map(workspace => workspace.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">Send messages and view channels</span>
)}
</div>
</div>
<div className="shrink-0">
{c.slackLoading || c.slackDiscovering ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.slackEnabled ? (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackDisable}
className="h-7 px-3 text-xs"
>
Disable
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
className="h-7 px-3 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-10 space-y-2">
{c.slackNeedsAuth ? (
<>
<p className="text-xs text-muted-foreground">
{c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'}
</p>
<div className="flex flex-wrap items-center gap-2.5">
<Button
size="sm"
onClick={c.handleSlackImportDesktop}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
>
{c.slackAuthImporting ? <Loader2 className="size-3 animate-spin" /> : "Connect Slack"}
</Button>
{isWindows && (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackQuitAndImport}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
title="Closes Slack so its data unlocks, then connects"
>
Quit Slack &amp; connect
</Button>
)}
<button
type="button"
onClick={() => c.setSlackCurlOpen(!c.slackCurlOpen)}
className="text-xs text-primary underline-offset-2 hover:underline"
>
Paste from browser instead
</button>
</div>
{c.slackCurlOpen && (
<div className="space-y-1.5">
<p className="text-[11px] leading-relaxed text-muted-foreground">
In a browser signed in to Slack, open DevTools Network, click any
request to <code>app.slack.com</code>, right-click Copy Copy as cURL,
then paste it below.
</p>
<Textarea
value={c.slackCurlValue}
onChange={(event) => c.setSlackCurlValue(event.target.value)}
placeholder="curl 'https://your-team.slack.com/api/...' -H 'Cookie: d=xoxd-...' ..."
className="min-h-20 text-[11px] font-mono"
disabled={c.slackCurlSubmitting}
/>
<Button
size="sm"
onClick={c.handleSlackParseCurl}
disabled={c.slackCurlSubmitting || c.slackCurlValue.trim().length === 0}
className="h-7 px-3 text-xs"
>
{c.slackCurlSubmitting ? <Loader2 className="size-3 animate-spin" /> : "Connect with cURL"}
</Button>
</div>
)}
</>
) : c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(workspace => (
<label key={workspace.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(workspace.url)}
onChange={(event) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (event.target.checked) next.add(workspace.url)
else next.delete(workspace.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{workspace.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
{/* Knowledge Sources Section */}
{c.slackEnabled && (
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Knowledge Sources
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack to knowledge</span>
<span className="text-xs text-muted-foreground truncate">
Sync selected channels into the knowledge graph
</span>
</div>
</div>
<Switch
checked={c.slackKnowledgeEnabled}
onCheckedChange={c.setSlackKnowledgeEnabled}
disabled={c.slackKnowledgeSaving}
/>
</div>
<div className="mt-2 space-y-2">
<Textarea
value={c.slackKnowledgeChannels}
onChange={(event) => c.setSlackKnowledgeChannels(event.target.value)}
placeholder={c.slackWorkspaces.length > 1 ? "https://team.slack.com #engineering" : "#engineering"}
className="min-h-20 text-xs"
disabled={!c.slackKnowledgeEnabled || c.slackKnowledgeSaving}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
One channel per line. Use channel names or IDs.
</span>
{(c.slackKnowledgeDirty || c.slackKnowledgeSaving) && (
<Button
size="sm"
onClick={c.handleSlackKnowledgeSave}
disabled={c.slackKnowledgeSaving || (c.slackKnowledgeEnabled && c.slackKnowledgeChannels.trim().length === 0)}
className="h-7 px-3 text-xs"
>
{c.slackKnowledgeSaving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
</Button>
)}
</div>
{c.slackKnowledgeEnabled && c.slackSyncStatuses.filter(s => s.enabled).map(status => (
<div key={status.id} className="flex items-center gap-1.5 text-xs">
{status.lastStatus === 'error' ? (
<span className="text-amber-600 truncate">
Sync failing {actionableSlackError(status.lastError?.kind, status.lastError?.message)}
</span>
) : status.lastSyncAt ? (
<span className="text-muted-foreground">Last synced {relativeTime(status.lastSyncAt)}</span>
) : (
<span className="text-muted-foreground">Not synced yet first sync runs shortly</span>
)}
</div>
))}
</div>
</div>
</>
)}
</div>
</>
)

View file

@ -0,0 +1,287 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import type { z } from "zod"
import { Loader2, MessageCircle, Send, Smartphone } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { toast } from "sonner"
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/src/channels.js"
type Config = z.infer<typeof ChannelsConfig>
type Status = z.infer<typeof ChannelsStatus>
// Comma/newline separated entries; each entry keeps digits only, so a
// formatted number like "+1 (415) 555-1234" survives as one entry instead of
// being shattered at the spaces.
function parseIdList(draft: string): string[] {
return draft
.split(/[,;\n]+/)
.map((s) => s.replace(/\D/g, ""))
.filter(Boolean)
}
export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [config, setConfig] = useState<Config | null>(null)
const [status, setStatus] = useState<Status | null>(null)
const [tokenDraft, setTokenDraft] = useState("")
const [waAllowDraft, setWaAllowDraft] = useState("")
const [tgAllowDraft, setTgAllowDraft] = useState("")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
void (async () => {
try {
const [cfg, st] = await Promise.all([
window.ipc.invoke("channels:getConfig", null),
window.ipc.invoke("channels:getStatus", null),
])
if (cancelled) return
setConfig(cfg)
setStatus(st)
setTokenDraft(cfg.telegram.botToken)
setWaAllowDraft(cfg.whatsapp.allowFrom.join(", "))
setTgAllowDraft(cfg.telegram.allowFrom.join(", "))
} catch {
if (!cancelled) toast.error("Failed to load mobile channel settings")
}
})()
const unsubscribe = window.ipc.on("channels:status", (st) => {
setStatus(st)
})
return () => {
cancelled = true
unsubscribe()
}
}, [dialogOpen])
const save = useCallback(async (next: Config) => {
setConfig(next)
setSaving(true)
try {
await window.ipc.invoke("channels:setConfig", next)
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save channel settings")
} finally {
setSaving(false)
}
}, [])
if (!config) {
return (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)
}
const wa = status?.whatsapp
const tg = status?.telegram
return (
<div className="space-y-6">
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
<Smartphone className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Chat with Rowboat from your phone. Send <span className="font-mono">help</span> for
commands (<span className="font-mono">list</span>, <span className="font-mono">resume 2</span>,{" "}
<span className="font-mono">new</span>, <span className="font-mono">stop</span>) anything
else is a message to the current chat. Your computer must be on with Rowboat running.
</p>
</div>
{/* WhatsApp */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageCircle className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">WhatsApp</span>
<span className="text-xs text-muted-foreground">
{wa?.state === "connected"
? `Linked as +${wa.self ?? "?"} — message yourself to use it`
: wa?.state === "qr"
? "Scan the QR below with your phone"
: wa?.state === "starting"
? "Connecting…"
: wa?.state === "error"
? wa.error ?? "Error"
: "Links your own WhatsApp as a companion device"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{wa?.state === "connected" && (
<Button
variant="outline"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => {
void window.ipc.invoke("channels:whatsappLogout", null).catch(() => {
toast.error("Failed to unlink WhatsApp")
})
}}
>
Unlink
</Button>
)}
<Switch
checked={config.whatsapp.enabled}
disabled={saving}
onCheckedChange={(enabled) =>
void save({ ...config, whatsapp: { ...config.whatsapp, enabled } })
}
/>
</div>
</div>
{config.whatsapp.enabled && wa?.state === "qr" && wa.qrDataUrl && (
<div className="flex items-center gap-4 rounded-md border p-3">
<img src={wa.qrDataUrl} alt="WhatsApp pairing QR" className="size-40 rounded" />
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Link Rowboat to WhatsApp</p>
<p>1. Open WhatsApp on your phone</p>
<p>2. Settings Linked Devices Link a Device</p>
<p>3. Scan this code</p>
<p className="pt-1">
Then message <span className="font-medium">yourself</span> (your own contact) to talk
to Rowboat.
</p>
</div>
</div>
)}
{config.whatsapp.enabled && (
<div className="space-y-1.5">
<label className="text-xs font-medium">Additional allowed numbers</label>
<div className="flex gap-2">
<Input
value={waAllowDraft}
onChange={(e) => setWaAllowDraft(e.target.value)}
placeholder="e.g. 14155551234, 919876543210"
className="h-8 text-xs"
/>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={saving}
onClick={() =>
void save({
...config,
whatsapp: { ...config.whatsapp, allowFrom: parseIdList(waAllowDraft) },
})
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Your own number (self-chat) is always allowed. Digits only, with country code.
</p>
</div>
)}
</div>
<Separator />
{/* Telegram */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Send className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">Telegram</span>
<span className="text-xs text-muted-foreground">
{tg?.state === "polling"
? `Listening${tg.botUsername ? ` as @${tg.botUsername}` : ""}`
: tg?.state === "starting"
? "Connecting…"
: tg?.state === "error"
? tg.error ?? "Error"
: "Uses your own bot — create one with @BotFather"}
</span>
</div>
</div>
<Switch
checked={config.telegram.enabled}
disabled={saving}
onCheckedChange={(enabled) =>
void save({ ...config, telegram: { ...config.telegram, enabled } })
}
/>
</div>
{config.telegram.enabled && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium">Bot token</label>
<div className="flex gap-2">
<Input
type="password"
value={tokenDraft}
onChange={(e) => setTokenDraft(e.target.value)}
placeholder="123456789:AAF…"
className="h-8 text-xs font-mono"
/>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={saving}
onClick={() =>
void save({
...config,
telegram: { ...config.telegram, botToken: tokenDraft.trim() },
})
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Message @BotFather on Telegram /newbot paste the token here.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium">Allowed chat IDs</label>
<div className="flex gap-2">
<Input
value={tgAllowDraft}
onChange={(e) => setTgAllowDraft(e.target.value)}
placeholder="e.g. 123456789"
className="h-8 text-xs"
/>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={saving}
onClick={() =>
void save({
...config,
telegram: { ...config.telegram, allowFrom: parseIdList(tgAllowDraft) },
})
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Message your bot once it replies with your chat ID to add here.
</p>
</div>
</>
)}
</div>
</div>
)
}

View file

@ -3,14 +3,17 @@
import * as React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
ArrowUpRight,
Bot,
ChevronRight,
Code2,
FileText,
FilePlus,
Folder,
Globe,
AlertTriangle,
Home,
LayoutGrid,
Mic,
SquarePen,
Plug,
@ -20,6 +23,8 @@ import {
Settings,
Square,
Video,
CircleAlert,
X,
} from "lucide-react"
import {
AlertDialog,
@ -49,6 +54,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverArrow,
} from "@/components/ui/popover"
import {
Tooltip,
@ -56,10 +62,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
import { SettingsDialog } from "@/components/settings-dialog"
import { MascotFaceIcon } from "@/components/talking-head"
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 +98,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 +165,22 @@ type SidebarContentPanelProps = {
knowledgeActions: KnowledgeActions
bgTaskSummaries?: TaskSummary[]
onOpenMeetings?: () => void
onOpenCode?: () => void
onOpenBgTasks?: () => void
onOpenApps?: () => 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
/** Starts the mascot-guided product tour. */
onStartTour?: () => 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' | 'apps' | 'workspaces' | null
/** Live meeting recording state, so the recording row can show its indicator/stop. */
meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping'
recordingMeetingSource?: string | null
@ -412,19 +414,21 @@ function SyncStatusBar() {
export function SidebarContentPanel({
tree,
onSelectFile,
knowledgeActions,
bgTaskSummaries = [],
onOpenMeetings,
onOpenCode,
onOpenBgTasks,
onOpenAgent,
onOpenApps,
recentRuns = [],
onOpenRun,
onOpenChatHistory,
onOpenEmail,
onOpenHome,
onNewChat,
onToggleBrowser,
onVoiceNoteCreated,
onStartTour,
activeNav,
meetingRecordingState = 'idle',
recordingMeetingSource = null,
@ -437,15 +441,35 @@ export function SidebarContentPanel({
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [creditPopoverOpen, setCreditPopoverOpen] = useState(false)
const [outOfCredits, setOutOfCredits] = useState(false)
const outOfCreditsRef = useRef(false)
const creditPopoverAutoShownRef = useRef(false)
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected)
const { billing, refresh: refreshBilling } = 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
@ -524,59 +548,16 @@ export function SidebarContentPanel({
.slice(0, 10)
}, [tree])
// Recents: most recently touched notes / agents / chats, interleaved by
// recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall.
type QuickAccessItem = {
key: string
label: string
recency: number
type: 'note' | 'agent' | 'chat'
onClick: () => void
}
const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => {
const items: QuickAccessItem[] = []
for (const note of recentNotes.slice(0, 4)) {
items.push({
key: `note:${note.path}`,
label: displayNoteName(note),
recency: note.stat?.mtimeMs ?? 0,
type: 'note',
onClick: () => onSelectFile(note.path, 'file'),
})
}
const agentRecency = (t: TaskSummary) => {
const ts = t.lastRunAt ?? t.lastAttemptAt ?? t.createdAt
const ms = ts ? new Date(ts).getTime() : 0
// Chats: the 5 most recently modified chats, newest first.
const recentChats = React.useMemo(() => {
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) {
items.push({
key: `agent:${t.slug}`,
label: t.name,
recency: agentRecency(t),
type: 'agent',
onClick: () => onOpenAgent?.(t.slug),
})
}
const chatRecency = (r: { createdAt: string }) => {
const ms = new Date(r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) {
items.push({
key: `chat:${r.id}`,
label: r.title || '(Untitled chat)',
recency: chatRecency(r),
type: 'chat',
onClick: () => onOpenRun?.(r.id),
})
}
return items.sort((a, b) => b.recency - a.recency).slice(0, 12)
}, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun])
return [...recentRuns]
.sort((a, b) => chatRecency(b) - chatRecency(a))
.slice(0, 10)
}, [recentRuns])
// Workspace count for the Workspaces sublabel — top-level dir children of
// knowledge/Workspace (matches WorkspaceView's root listing).
@ -688,6 +669,50 @@ export function SidebarContentPanel({
}
}, [])
// Re-anchor the warning whenever billing (re)loads — billing is authoritative.
useEffect(() => {
if (billing) {
const next = isOutOfCredits(billing)
outOfCreditsRef.current = next
setOutOfCredits(next)
}
}, [billing])
// Live signals: a usage API error flips it on; a successful cost-incurring
// call flips it off and triggers a single billing refresh to reconcile.
useEffect(() => {
const onExhausted = () => {
outOfCreditsRef.current = true
setOutOfCredits(true)
}
const onReplenished = () => {
const wasOut = outOfCreditsRef.current
outOfCreditsRef.current = false
setOutOfCredits(false)
if (wasOut) void refreshBilling()
}
window.addEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
window.addEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
return () => {
window.removeEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
window.removeEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
}
}, [refreshBilling])
// Auto-open the popover the first time we go out of credits; reset when
// credits return so it can auto-open again on a future episode.
useEffect(() => {
if (outOfCredits) {
if (!creditPopoverAutoShownRef.current) {
creditPopoverAutoShownRef.current = true
setCreditPopoverOpen(true)
}
} else {
creditPopoverAutoShownRef.current = false
setCreditPopoverOpen(false)
}
}, [outOfCredits])
// Single preview shown as a sublabel on the Email / Meetings nav buttons.
const previewEmail = emailThreads[0]
const previewMeeting = meetings[0]
@ -729,13 +754,14 @@ export function SidebarContentPanel({
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive={activeNav === 'home'} onClick={onOpenHome}>
<SidebarMenuButton data-tour-id="nav-home" isActive={activeNav === 'home'} onClick={onOpenHome}>
<Home className="size-4 shrink-0" />
<span className="flex-1 truncate">Home</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-email"
isActive={activeNav === 'email'}
onClick={() => onOpenEmail?.()}
className={previewEmail ? 'h-auto py-1.5' : undefined}
@ -758,6 +784,7 @@ export function SidebarContentPanel({
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-meetings"
isActive={activeNav === 'meetings'}
onClick={onOpenMeetings}
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
@ -834,15 +861,24 @@ export function SidebarContentPanel({
</div>
) : null}
</SidebarMenuItem>
{codeModeEnabled && (
<SidebarMenuItem>
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
<Code2 className="size-4 shrink-0" />
<span className="flex-1 truncate">Code</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-knowledge"
isActive={activeNav === 'knowledge'}
onClick={() => knowledgeActions.openKnowledgeView()}
className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined}
>
<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>
)}
@ -856,6 +892,7 @@ export function SidebarContentPanel({
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-agents"
isActive={activeNav === 'agents'}
onClick={onOpenBgTasks}
className={bgAgentsLabel ? 'h-auto py-1.5' : undefined}
@ -876,6 +913,17 @@ export function SidebarContentPanel({
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-apps"
isActive={activeNav === 'apps'}
onClick={onOpenApps}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-muted-foreground">Apps</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-workspaces"
isActive={activeNav === 'workspaces'}
onClick={() => knowledgeActions.openWorkspaceAt()}
className="h-auto py-1.5"
@ -895,38 +943,44 @@ 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)}
data-tour-id="nav-chats"
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>
)
)}
@ -934,31 +988,89 @@ export function SidebarContentPanel({
</SidebarGroup>
</SidebarContent>
{/* Billing / upgrade CTA or Log in CTA */}
{isRowboatConnected && billing ? (
<div className="px-3 py-2">
<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)}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
return (
<p className="text-[10px] text-sidebar-foreground/60">
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
{isRowboatConnected && billing ? (() => {
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
if (outOfCredits) {
return (
<div className="px-3 py-2">
<Popover open={creditPopoverOpen} onOpenChange={setCreditPopoverOpen}>
<div className="flex items-center justify-between rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-2">
<PopoverTrigger asChild>
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left">
<AlertTriangle className="size-4 shrink-0 text-red-500" />
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</span>
<p className="text-[10px] text-red-500">Out of credits</p>
</div>
</button>
</PopoverTrigger>
<button
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"
>
{upgradeLabel}
</button>
</div>
<PopoverContent side="top" align="start" sideOffset={10} className="w-72">
<PopoverArrow className="fill-popover" />
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-red-500/15 text-red-500">
<CircleAlert className="size-4" />
</span>
<h4 className="text-sm font-bold text-foreground">You&apos;ve run out of credits</h4>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setCreditPopoverOpen(false)}
className="rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Upgrade your plan to continue using all features.
</p>
)
})()}
<button
onClick={() => { appUrl && window.open(`${appUrl}?intent=upgrade`); setCreditPopoverOpen(false) }}
className="mt-3 w-full rounded-md bg-red-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-600"
>
Upgrade now
</button>
</PopoverContent>
</Popover>
</div>
)
}
return (
<div className="px-3 py-2">
<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">
{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)))
return (
<p className="text-[10px] text-sidebar-foreground/60">
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
</p>
)
})()}
</div>
<button
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"
>
{upgradeLabel}
</button>
</div>
<button
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'}
</button>
</div>
</div>
) : null}
)
})() : null}
{/* Sign in CTA */}
{!isRowboatConnected && (
<div className="px-3 py-2">
@ -1036,6 +1148,15 @@ export function SidebarContentPanel({
</AlertDialog>
)}
</div>
{onStartTour && (
<button
onClick={onStartTour}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<MascotFaceIcon className="size-4" />
<span>Take a tour</span>
</button>
)}
<SettingsDialog>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Settings className="size-4" />

View file

@ -0,0 +1,487 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import type { TTSState } from '@/hooks/useVoiceTTS'
import { cn } from '@/lib/utils'
const POSITION_STORAGE_KEY = 'talking-head-position'
// Must match the overlay's `bottom-28 right-8` anchor classes.
const ANCHOR_RIGHT_PX = 32
const ANCHOR_BOTTOM_PX = 112
const VIEWPORT_MARGIN_PX = 8
// Palette pulled from the mascot artwork: pale lavender body, dark walnut boat.
const BODY_FILL = '#E8E9F5'
const BODY_STROKE = '#17171B'
const CHEEK_FILL = '#F2B8BE'
const BOAT_DARK = '#3E2E24'
const BOAT_MID = '#54402F'
const BOAT_LIGHT = '#6B5138'
const MOUTH_FILL = '#2A1E19'
export type MascotHat =
| 'mailcap'
| 'headphones'
| 'hardhat'
| 'gradcap'
| 'captain'
| 'explorer'
| 'party'
type TalkingHeadProps = {
ttsState: TTSState
getLevel: () => number
size?: number
/** Costume piece drawn on the head (used by the product tour). */
hat?: MascotHat
/** Paddle hard: fast bobbing + oar strokes, e.g. while traveling in the tour. */
rowing?: boolean
}
/**
* The Rowboat mascot as an animated inline SVG: a round pale character sitting
* in a wooden rowboat holding an oar. The mouth is driven every animation
* frame from the live TTS audio level; eyes blink on a randomized timer.
*/
export function TalkingHead({ ttsState, getLevel, size = 160, hat, rowing = false }: TalkingHeadProps) {
const mouthOpenRef = useRef<SVGEllipseElement>(null)
const mouthSmileRef = useRef<SVGPathElement>(null)
const oarRef = useRef<SVGGElement>(null)
const smoothedRef = useRef(0)
const [blinking, setBlinking] = useState(false)
const speaking = ttsState === 'speaking'
const thinking = ttsState === 'synthesizing'
// Lip sync + oar paddle loop. Writes SVG attributes directly to avoid
// re-rendering React at 60fps. Stops itself once speech has ended and the
// mouth has settled closed; restarts when `speaking` flips this effect.
useEffect(() => {
let raf = 0
let t = 0
const tick = () => {
const target = speaking ? getLevel() : 0
const prev = smoothedRef.current
// Fast attack, slower decay reads as natural mouth movement
const smoothed = target > prev ? prev + (target - prev) * 0.5 : prev + (target - prev) * 0.2
const settled = !speaking && !rowing && smoothed < 0.005
smoothedRef.current = settled ? 0 : smoothed
const open = settled ? 0 : Math.min(1, smoothed * 1.6)
const mouthOpen = mouthOpenRef.current
const mouthSmile = mouthSmileRef.current
if (mouthOpen && mouthSmile) {
if (open > 0.06) {
mouthOpen.setAttribute('rx', String(6.5 + open * 4))
mouthOpen.setAttribute('ry', String(1.5 + open * 9))
mouthOpen.style.opacity = '1'
mouthSmile.style.opacity = '0'
} else {
mouthOpen.style.opacity = '0'
mouthSmile.style.opacity = '1'
}
}
const oar = oarRef.current
if (oar) {
if (rowing) {
t += 0.14
const angle = Math.sin(t) * 13
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
} else if (speaking) {
t += 0.045
const angle = Math.sin(t) * 7
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
} else {
oar.setAttribute('transform', 'rotate(0 128 118)')
}
}
if (!settled) {
raf = requestAnimationFrame(tick)
}
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [speaking, rowing, getLevel])
// Randomized blinking
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>
let cancelled = false
const scheduleBlink = () => {
timeout = setTimeout(() => {
if (cancelled) return
setBlinking(true)
setTimeout(() => {
if (cancelled) return
setBlinking(false)
scheduleBlink()
}, 140)
}, 2400 + Math.random() * 2600)
}
scheduleBlink()
return () => {
cancelled = true
clearTimeout(timeout)
}
}, [])
return (
<div
className="talking-head-bob relative select-none"
style={{
width: size,
height: size,
animationDuration: rowing ? '0.8s' : speaking ? '1.6s' : '3.2s',
}}
>
<style>{`
@keyframes talking-head-bob {
0%, 100% { transform: translateY(0) rotate(-1.6deg); }
50% { transform: translateY(-4px) rotate(1.6deg); }
}
.talking-head-bob {
animation-name: talking-head-bob;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes talking-head-ripple {
0% { transform: scale(0.6); opacity: 0.5; }
100% { transform: scale(1.25); opacity: 0; }
}
.talking-head-ripple {
transform-origin: center;
transform-box: fill-box;
animation: talking-head-ripple 2.6s ease-out infinite;
}
@keyframes talking-head-bubble {
0%, 100% { opacity: 0.25; transform: translateY(0); }
50% { opacity: 1; transform: translateY(-2px); }
}
.talking-head-bubble {
animation: talking-head-bubble 1.2s ease-in-out infinite;
}
`}</style>
<svg viewBox="0 0 200 190" width={size} height={size} aria-hidden="true">
{/* water ripples under the boat */}
<g>
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '0s' }} />
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '1.3s' }} />
<ellipse cx="100" cy="168" rx="52" ry="7" fill="#8FB6D9" opacity="0.18" />
</g>
{/* thinking bubbles while synthesizing */}
{thinking && (
<g fill={BODY_STROKE} opacity="0.75">
<circle className="talking-head-bubble" cx="146" cy="34" r="3" style={{ animationDelay: '0s' }} />
<circle className="talking-head-bubble" cx="157" cy="26" r="4.2" style={{ animationDelay: '0.2s' }} />
<circle className="talking-head-bubble" cx="170" cy="16" r="5.4" style={{ animationDelay: '0.4s' }} />
</g>
)}
{/* character: head + body blob */}
<g>
<path
d="M 100 22
C 129 22 148 43 148 68
C 148 82 141 93 131 100
C 141 107 147 117 148 128
L 52 128
C 53 115 60 105 69 99
C 59 92 52 81 52 68
C 52 43 71 22 100 22 Z"
fill={BODY_FILL}
stroke={BODY_STROKE}
strokeWidth="5"
strokeLinejoin="round"
/>
{/* eyes */}
<g style={{ transform: thinking ? 'translateY(-2.5px)' : undefined, transition: 'transform 0.3s' }}>
<ellipse
cx="84" cy="64" rx="5" ry={blinking ? 0.8 : 7}
fill={BODY_STROKE}
style={{ transition: 'ry 0.06s' }}
/>
<ellipse
cx="116" cy="64" rx="5" ry={blinking ? 0.8 : 7}
fill={BODY_STROKE}
style={{ transition: 'ry 0.06s' }}
/>
<circle cx="86" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
<circle cx="118" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
</g>
{/* cheeks */}
<ellipse cx="72" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
<ellipse cx="128" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
{/* mouth: smile when quiet, open ellipse driven by audio level */}
<path
ref={mouthSmileRef}
d="M 91 80 Q 100 88 109 80"
fill="none"
stroke={BODY_STROKE}
strokeWidth="4"
strokeLinecap="round"
/>
<ellipse
ref={mouthOpenRef}
cx="100" cy="84" rx="7" ry="2"
fill={MOUTH_FILL}
stroke={BODY_STROKE}
strokeWidth="3"
style={{ opacity: 0 }}
/>
{hat && <MascotHatArt hat={hat} />}
</g>
{/* oar (rotates while speaking) */}
<g ref={oarRef}>
<line x1="158" y1="88" x2="88" y2="152" stroke={BODY_STROKE} strokeWidth="12" strokeLinecap="round" />
<line x1="158" y1="88" x2="88" y2="152" stroke={BOAT_MID} strokeWidth="7" strokeLinecap="round" />
<path
d="M 84 148 L 56 170 C 52 173 52 178 57 178 L 90 165 Z"
fill={BOAT_DARK}
stroke={BODY_STROKE}
strokeWidth="4"
strokeLinejoin="round"
/>
</g>
{/* hand resting over the oar */}
<ellipse cx="121" cy="120" rx="10" ry="8" fill={BODY_FILL} stroke={BODY_STROKE} strokeWidth="4" />
{/* boat hull (drawn last so it overlaps the body) */}
<g>
<path
d="M 30 120
C 50 132 150 132 170 120
C 168 142 152 160 100 160
C 48 160 32 142 30 120 Z"
fill={BOAT_MID}
stroke={BODY_STROKE}
strokeWidth="5"
strokeLinejoin="round"
/>
{/* plank lines */}
<path d="M 36 133 C 60 143 140 143 164 133" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
<path d="M 44 145 C 66 153 134 153 156 145" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
{/* gunwale highlight */}
<path d="M 33 121 C 52 131 148 131 167 121" fill="none" stroke={BOAT_LIGHT} strokeWidth="4" strokeLinecap="round" />
</g>
</svg>
</div>
)
}
type TalkingHeadOverlayProps = {
ttsState: TTSState
getLevel: () => number
onDismiss?: () => void
}
// Keep the widget fully on-screen relative to its bottom-right CSS anchor.
// Falls back to the default render size when the element isn't mounted yet.
function clampPositionToViewport(pos: { x: number; y: number }, el: HTMLDivElement | null): { x: number; y: number } {
const width = el?.offsetWidth ?? 160
const height = el?.offsetHeight ?? 160
const baseLeft = window.innerWidth - ANCHOR_RIGHT_PX - width
const baseTop = window.innerHeight - ANCHOR_BOTTOM_PX - height
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
return {
x: clamp(pos.x, VIEWPORT_MARGIN_PX - baseLeft, window.innerWidth - VIEWPORT_MARGIN_PX - width - baseLeft),
y: clamp(pos.y, VIEWPORT_MARGIN_PX - baseTop, window.innerHeight - VIEWPORT_MARGIN_PX - height - baseTop),
}
}
function loadStoredPosition(): { x: number; y: number } {
try {
const raw = localStorage.getItem(POSITION_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
return parsed
}
}
} catch {
// ignore corrupt stored position
}
return { x: 0, y: 0 }
}
/**
* Floating, draggable widget that hosts the talking head. Anchored to the
* bottom-right of the window (above the composer) and offset by a persisted
* drag position, so it hovers over whatever view is active.
*/
export function TalkingHeadOverlay({ ttsState, getLevel, onDismiss }: TalkingHeadOverlayProps) {
// Clamp the stored offset at init so a stale position (e.g. saved on a
// bigger window) can't leave the widget stranded off-screen.
const [offset, setOffset] = useState(() => clampPositionToViewport(loadStoredPosition(), null))
const [dragging, setDragging] = useState(false)
const dragStartRef = useRef<{ pointerX: number; pointerY: number; x: number; y: number } | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const clampToViewport = useCallback(
(pos: { x: number; y: number }) => clampPositionToViewport(pos, containerRef.current),
[]
)
// Re-clamp when the window shrinks
useEffect(() => {
const handleResize = () => setOffset(prev => clampToViewport(prev))
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [clampToViewport])
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return
dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, x: offset.x, y: offset.y }
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}, [offset])
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const start = dragStartRef.current
if (!start) return
setOffset({
x: start.x + (e.clientX - start.pointerX),
y: start.y + (e.clientY - start.pointerY),
})
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragStartRef.current) return
dragStartRef.current = null
setDragging(false)
setOffset(prev => clampToViewport(prev))
e.currentTarget.releasePointerCapture(e.pointerId)
}, [clampToViewport])
useEffect(() => {
if (dragging) return
try {
localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(offset))
} catch {
// best-effort persistence
}
}, [offset, dragging])
return (
<div
ref={containerRef}
className={cn(
'group fixed bottom-28 right-8 z-50 touch-none',
dragging ? 'cursor-grabbing' : 'cursor-grab'
)}
style={{
transform: `translate(${offset.x}px, ${offset.y}px)`,
// Constant value so the entrance animation runs once on mount and
// never restarts (re-applying it after a drag would replay the pop).
animation: 'talking-head-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
role="img"
aria-label="Rowboat talking head"
>
<style>{`
@keyframes talking-head-pop {
0% { opacity: 0; scale: 0.4; }
100% { opacity: 1; scale: 1; }
}
`}</style>
{onDismiss && (
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={onDismiss}
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground group-hover:opacity-100"
aria-label="Hide talking head"
>
<X className="h-3 w-3" />
</button>
)}
<TalkingHead ttsState={ttsState} getLevel={getLevel} />
</div>
)
}
/** Costume pieces for the product tour, drawn on the mascot's head. */
function MascotHatArt({ hat }: { hat: MascotHat }) {
const outline = { stroke: BODY_STROKE, strokeWidth: 4, strokeLinejoin: 'round' as const }
switch (hat) {
case 'mailcap':
return (
<g>
<path d="M 68 36 Q 100 4 132 36 Q 100 26 68 36 Z" fill="#4A7DDB" {...outline} />
<path d="M 126 33 Q 148 31 150 39 Q 132 42 124 39 Z" fill="#3D68B8" {...outline} />
</g>
)
case 'headphones':
return (
<g>
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke={BODY_STROKE} strokeWidth="11" strokeLinecap="round" />
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke="#4B5563" strokeWidth="6" strokeLinecap="round" />
<ellipse cx="64" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
<ellipse cx="136" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
</g>
)
case 'hardhat':
return (
<g>
<path d="M 66 38 Q 100 0 134 38 Z" fill="#F6C445" {...outline} />
<path d="M 96 10 Q 94 22 96 36 L 106 36 Q 107 22 105 9 Z" fill="#E0AC2C" stroke="none" />
<path d="M 56 38 L 144 38" fill="none" stroke={BODY_STROKE} strokeWidth="9" strokeLinecap="round" />
<path d="M 58 38 L 142 38" fill="none" stroke="#F6C445" strokeWidth="5" strokeLinecap="round" />
</g>
)
case 'gradcap':
return (
<g>
<path d="M 80 28 Q 100 42 120 28 L 120 38 Q 100 50 80 38 Z" fill="#232838" {...outline} />
<path d="M 100 2 L 144 20 L 100 38 L 56 20 Z" fill="#2E3450" {...outline} />
<path d="M 100 20 Q 124 26 136 34" fill="none" stroke="#E8B94A" strokeWidth="3" strokeLinecap="round" />
<circle cx="137" cy="38" r="4" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
</g>
)
case 'captain':
return (
<g>
<path d="M 68 36 Q 100 -2 132 36 Q 100 28 68 36 Z" fill="#F4F5F9" {...outline} />
<path d="M 66 36 Q 100 46 134 36 L 134 42 Q 100 52 66 42 Z" fill="#22262E" {...outline} />
<path d="M 122 42 Q 146 42 148 48 Q 130 52 120 48 Z" fill="#22262E" {...outline} />
<circle cx="100" cy="38" r="4.5" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
</g>
)
case 'explorer':
return (
<g>
<ellipse cx="100" cy="35" rx="46" ry="9" fill="#C9A46B" {...outline} />
<path d="M 72 34 Q 100 4 128 34 Z" fill="#C9A46B" {...outline} />
<path d="M 76 30 Q 100 38 124 30" fill="none" stroke="#8A6B3D" strokeWidth="4" strokeLinecap="round" />
</g>
)
case 'party':
return (
<g>
<path d="M 100 -2 L 84 34 L 116 34 Z" fill="#F2699C" {...outline} />
<path d="M 92 16 L 111 22 M 88 26 L 114 31" fill="none" stroke="#FFD166" strokeWidth="3.5" strokeLinecap="round" />
<circle cx="100" cy="0" r="5" fill="#FFD166" stroke={BODY_STROKE} strokeWidth="3" />
</g>
)
}
}
/** Small static mascot face used as the toolbar toggle icon. */
export function MascotFaceIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<ellipse cx="8.6" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
<ellipse cx="15.4" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
<path d="M 9 14.5 Q 12 17 15 14.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)
}

View file

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

View file

@ -0,0 +1,262 @@
import { useEffect } from 'react'
export type MascotVignetteKind = 'email' | 'meetings' | 'brain'
export type TourVignetteKind = MascotVignetteKind | 'agents'
/**
* Little looping "shows" staged around the mascot while it presents a section
* during the product tour. Purely decorative: everything is pointer-events-none
* and rendered on the tour's own layers, never inside the section's real UI.
*/
export function MascotVignette({ kind, playDing }: { kind: MascotVignetteKind; playDing?: () => void }) {
// One round of dings as the first envelopes land, then let the loop run silent
useEffect(() => {
if (kind !== 'email' || !playDing) return
const timers = [900, 1900, 2900].map((ms) => setTimeout(playDing, ms))
return () => timers.forEach(clearTimeout)
}, [kind, playDing])
return (
<div className="pointer-events-none absolute left-1/2 top-0 z-0 -translate-x-1/2" aria-hidden="true">
<style>{`
@keyframes tour-env-left {
0% { opacity: 0; transform: translate(-150px, -130px) rotate(-26deg); }
10% { opacity: 1; }
52% { opacity: 1; transform: translate(0px, 0px) rotate(5deg); }
64% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
100% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
}
@keyframes tour-env-right {
0% { opacity: 0; transform: translate(150px, -140px) rotate(26deg); }
10% { opacity: 1; }
52% { opacity: 1; transform: translate(0px, 0px) rotate(-5deg); }
64% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
100% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
}
@keyframes tour-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
@keyframes tour-note-line {
0% { width: 0; }
18% { width: var(--w); }
80% { width: var(--w); opacity: 1; }
92%, 100% { width: var(--w); opacity: 0; }
}
@keyframes tour-quill-bob {
0% { transform: translate(4px, 26px) rotate(-8deg); }
25% { transform: translate(46px, 30px) rotate(4deg); }
50% { transform: translate(6px, 40px) rotate(-8deg); }
75% { transform: translate(48px, 44px) rotate(4deg); }
100% { transform: translate(4px, 26px) rotate(-8deg); }
}
@keyframes tour-voice-bar {
0%, 100% { transform: scaleY(0.35); }
50% { transform: scaleY(1); }
}
@keyframes tour-orb {
0% { opacity: 0; transform: translate(var(--from-x), var(--from-y)) scale(0.5); }
15% { opacity: 0.9; }
70% { opacity: 0.9; transform: translate(0, 0) scale(1); }
85%, 100% { opacity: 0; transform: translate(0, 6px) scale(0.3); }
}
@keyframes tour-node-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes tour-edge-draw {
from { stroke-dashoffset: 60; }
to { stroke-dashoffset: 0; }
}
`}</style>
{kind === 'email' && (
<div className="relative" style={{ width: 240, height: 150 }}>
{[
{ anim: 'tour-env-left', delay: 0, x: 78, y: 96 },
{ anim: 'tour-env-right', delay: 1.0, x: 128, y: 102 },
{ anim: 'tour-env-left', delay: 2.0, x: 104, y: 92 },
{ anim: 'tour-env-right', delay: 3.0, x: 90, y: 104 },
].map((e, i) => (
<div
key={i}
className="absolute"
style={{ left: e.x, top: e.y, animation: `${e.anim} 4s ease-in-out ${e.delay}s infinite both` }}
>
<svg width="34" height="24" viewBox="0 0 34 24">
<rect x="1.5" y="1.5" width="31" height="21" rx="3" fill="#FFF8E7" stroke="#17171B" strokeWidth="2.5" />
<path d="M 2 4 L 17 14 L 32 4" fill="none" stroke="#17171B" strokeWidth="2.5" strokeLinejoin="round" />
</svg>
</div>
))}
<div
className="absolute"
style={{ left: 148, top: 78, animation: 'tour-badge-pulse 2s ease-in-out infinite' }}
>
<svg width="22" height="22" viewBox="0 0 22 22">
<circle cx="11" cy="11" r="9.5" fill="#3FA95C" stroke="#17171B" strokeWidth="2.5" />
<path d="M 6.5 11.5 L 9.5 14.5 L 15.5 8" fill="none" stroke="#FFFFFF" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
)}
{kind === 'meetings' && (
<div className="relative" style={{ width: 220, height: 160, top: -110 }}>
{/* someone's talking */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-end gap-1" style={{ top: 0, height: 26 }}>
{[0.9, 1.1, 0.7, 1.3, 0.8].map((dur, i) => (
<div
key={i}
className="w-1.5 origin-bottom rounded-full bg-[#8FB6D9]"
style={{ height: 22, animation: `tour-voice-bar ${dur}s ease-in-out ${i * 0.12}s infinite` }}
/>
))}
</div>
{/* ...and the notepad writes itself */}
<div
className="absolute left-1/2 rounded-md border-2 border-[#17171B] bg-[#FFFDF6] shadow-md"
style={{ top: 36, width: 96, height: 104, transform: 'translateX(-50%) rotate(-3deg)' }}
>
<div className="mx-2 mt-2 h-1.5 rounded bg-[#D9534F]/70" />
{[64, 52, 60, 44].map((w, i) => (
<div
key={i}
className="ml-2 mt-3 h-1.5 rounded bg-[#9AA1AE]"
style={{ ['--w' as string]: `${w}px`, animation: `tour-note-line 5s ease-out ${i * 1.1}s infinite both` }}
/>
))}
<svg
className="absolute left-0 top-0"
width="26"
height="26"
viewBox="0 0 26 26"
style={{ animation: 'tour-quill-bob 5s ease-in-out infinite' }}
>
<path d="M 4 22 L 10 12 Q 14 4 22 2 Q 18 10 12 14 Z" fill="#6B5138" stroke="#17171B" strokeWidth="2" strokeLinejoin="round" />
</svg>
</div>
</div>
)}
{kind === 'brain' && (
<div className="relative" style={{ width: 260, height: 180, top: -128 }}>
{/* constellation assembling above the head */}
<svg className="absolute left-1/2 -translate-x-1/2" width="140" height="80" viewBox="0 0 140 80" style={{ top: 0 }}>
{[
'M 22 58 L 52 24',
'M 52 24 L 88 40',
'M 88 40 L 120 18',
'M 88 40 L 70 66',
].map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke="#9CCBEA"
strokeWidth="2"
strokeDasharray="60"
style={{ animation: `tour-edge-draw 1.2s ease-out ${0.4 + i * 0.35}s both` }}
/>
))}
{[
[22, 58], [52, 24], [88, 40], [120, 18], [70, 66],
].map(([cx, cy], i) => (
<circle
key={i}
cx={cx}
cy={cy}
r={5}
fill="#BFE0F5"
stroke="#17171B"
strokeWidth="2"
style={{ animation: `tour-node-pulse 2.4s ease-in-out ${i * 0.3}s infinite` }}
/>
))}
</svg>
{/* thought-orbs drifting into the head */}
{[
{ fx: '-120px', fy: '-30px', delay: 0 },
{ fx: '120px', fy: '-40px', delay: 1.1 },
{ fx: '-90px', fy: '50px', delay: 2.2 },
{ fx: '110px', fy: '46px', delay: 3.3 },
].map((o, i) => (
<div
key={i}
className="absolute rounded-full"
style={{
left: 122,
top: 118,
width: 16,
height: 16,
background: 'radial-gradient(circle, #FFF3B8 20%, #FFD166 60%, transparent 75%)',
['--from-x' as string]: o.fx,
['--from-y' as string]: o.fy,
animation: `tour-orb 4.4s ease-in-out ${o.delay}s infinite both`,
}}
/>
))}
</div>
)}
</div>
)
}
/**
* Background-agents vignette: a fleet of tiny mascots rowing across the water
* at the bottom of the screen while the big one takes a break.
*/
export function AgentsFleet() {
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[67] h-28 overflow-hidden" aria-hidden="true">
<style>{`
@keyframes tour-fleet-right {
from { transform: translateX(-18vw); }
to { transform: translateX(112vw); }
}
@keyframes tour-fleet-left {
from { transform: translateX(112vw); }
to { transform: translateX(-18vw); }
}
@keyframes tour-fleet-bob {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-3px) rotate(2deg); }
}
`}</style>
{[
{ size: 46, bottom: 4, dur: 16, delay: 0, dir: 'tour-fleet-right' },
{ size: 34, bottom: 22, dur: 22, delay: -8, dir: 'tour-fleet-left' },
{ size: 28, bottom: 14, dur: 27, delay: -3, dir: 'tour-fleet-right' },
].map((b, i) => (
<div
key={i}
className="absolute left-0"
style={{ bottom: b.bottom, animation: `${b.dir} ${b.dur}s linear ${b.delay}s infinite` }}
>
<div style={{ animation: 'tour-fleet-bob 1.4s ease-in-out infinite', transform: b.dir === 'tour-fleet-left' ? 'scaleX(-1)' : undefined }}>
<MiniRower size={b.size} flipped={b.dir === 'tour-fleet-left'} />
</div>
</div>
))}
</div>
)
}
function MiniRower({ size, flipped }: { size: number; flipped: boolean }) {
return (
<svg
width={size}
height={size * 0.75}
viewBox="0 0 60 45"
style={{ transform: flipped ? 'scaleX(-1)' : undefined }}
>
<circle cx="27" cy="13" r="10" fill="#E8E9F5" stroke="#17171B" strokeWidth="3" />
<circle cx="23.5" cy="11.5" r="1.4" fill="#17171B" />
<circle cx="30.5" cy="11.5" r="1.4" fill="#17171B" />
<path d="M 23 16 Q 27 19 31 16" fill="none" stroke="#17171B" strokeWidth="1.6" strokeLinecap="round" />
<line x1="40" y1="14" x2="20" y2="38" stroke="#17171B" strokeWidth="4.5" strokeLinecap="round" />
<line x1="40" y1="14" x2="20" y2="38" stroke="#54402F" strokeWidth="2.5" strokeLinecap="round" />
<path d="M 7 25 C 17 31 43 31 53 25 C 51 37 43 42 30 42 C 17 42 9 37 7 25 Z" fill="#54402F" stroke="#17171B" strokeWidth="3" strokeLinejoin="round" />
</svg>
)
}

View file

@ -43,4 +43,17 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }

View file

@ -0,0 +1,274 @@
import { useEffect, useRef, useState } from 'react'
import { Mic, MicOff, Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react'
import { MascotFaceIcon, TalkingHead } from '@/components/talking-head'
import type { TTSState } from '@/hooks/useVoiceTTS'
import { cn } from '@/lib/utils'
export type VideoCallStatus = 'listening' | 'thinking' | 'speaking'
interface VideoCallViewProps {
/** Live camera stream from useVideoMode — attached to the user's tile. */
streamRef: React.MutableRefObject<MediaStream | null>
cameraOn: boolean
onToggleCamera: () => void
/** User mute = full input pause: no mic audio AND no frame capture. */
micMuted: boolean
onToggleMic: () => void
/** Starting a share collapses this view into the floating popout (the
* surface is derived from devices see App.tsx). */
onToggleScreenShare: () => void
/** Practice preset: the assistant is coaching this session. */
practiceMode?: boolean
/** Shrink to the floating pill without touching any devices. */
onMinimize: () => void
/** Stop the assistant: silence speech and abort the run if still going. */
onInterrupt: () => void
ttsState: TTSState
/** Live TTS output level — drives the mascot's mouth animation. */
getTtsLevel: () => number
status: VideoCallStatus
/** Live transcript of the user's in-progress utterance. */
interimText?: string
/** The assistant line currently being spoken aloud. */
assistantCaption?: string
onLeave: () => void
}
const STATUS_DISPLAY: Record<VideoCallStatus, { label: string; dotClass: string }> = {
listening: { label: 'Listening', dotClass: 'bg-green-500 animate-pulse' },
thinking: { label: 'Thinking…', dotClass: 'bg-amber-400' },
speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' },
}
/**
* Full-screen call surface: a Meet-style two-tile layout with the user's
* webcam on one side and the mascot as the other participant. Shown only
* while the camera is on with no screen share (the derived-surface rule in
* App.tsx) sharing or muting the camera moves the call into the floating
* popout. The mascot animates with the assistant's speech; dismissing it
* swaps in a Meet-style letter avatar ("R"). Live captions run along the
* bottom.
*/
export function VideoCallView({
streamRef,
cameraOn,
onToggleCamera,
micMuted,
onToggleMic,
onToggleScreenShare,
practiceMode,
onMinimize,
onInterrupt,
ttsState,
getTtsLevel,
status,
interimText,
assistantCaption,
onLeave,
}: VideoCallViewProps) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [mascotVisible, setMascotVisible] = useState(true)
useEffect(() => {
if (!cameraOn) return
const videoEl = videoRef.current
if (!videoEl) return
videoEl.srcObject = streamRef.current
videoEl.play().catch(() => {})
return () => {
videoEl.srcObject = null
}
}, [streamRef, cameraOn])
const userSpeaking = status === 'listening' && Boolean(interimText)
const assistantSpeaking = ttsState === 'speaking'
const caption = assistantSpeaking && assistantCaption
? { who: 'Rowboat', text: assistantCaption }
: interimText
? { who: 'You', text: interimText }
: null
return (
<div className="fixed inset-0 z-[100] flex flex-col bg-neutral-950">
{practiceMode && (
<span className="absolute left-4 top-4 z-10 flex items-center gap-1.5 rounded-full bg-violet-600/90 px-3 py-1 text-xs font-medium text-white">
<Presentation className="h-3.5 w-3.5" />
Practice session
</span>
)}
<button
type="button"
onClick={onMinimize}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-white/80 transition-colors hover:bg-neutral-700 hover:text-white"
aria-label="Minimize call (shares your screen)"
title="Minimize — shares your screen so it can help you work"
>
<Minimize2 className="h-4 w-4" />
</button>
{/* Participant tiles */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-4 pb-2 md:grid-cols-2">
{/* User */}
<div
className={cn(
'relative flex items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 transition-shadow',
userSpeaking && 'ring-2 ring-green-500/80'
)}
>
{cameraOn ? (
<video
ref={videoRef}
muted
playsInline
className="h-full w-full object-cover"
style={{ transform: 'scaleX(-1)' }}
/>
) : (
<span className="flex h-40 w-40 items-center justify-center rounded-full bg-neutral-700 text-neutral-400" aria-label="Camera off">
<User className="h-20 w-20" />
</span>
)}
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
You
</span>
{micMuted && (
<span className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2 py-0.5 text-sm font-medium text-white">
<MicOff className="h-3.5 w-3.5" />
Muted nothing is heard or captured
</span>
)}
</div>
{/* Assistant */}
<div
className={cn(
'group relative flex items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 transition-shadow',
assistantSpeaking && 'ring-2 ring-sky-400/80'
)}
>
{mascotVisible ? (
<TalkingHead ttsState={ttsState} getLevel={getTtsLevel} size={220} />
) : (
<span
className="flex h-40 w-40 items-center justify-center rounded-full bg-sky-600 text-7xl font-medium text-white"
aria-label="Rowboat"
>
R
</span>
)}
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
Rowboat
</span>
{status !== 'listening' && (
<button
type="button"
onClick={onInterrupt}
className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2.5 py-1 text-sm font-medium text-white transition-colors hover:bg-red-500"
aria-label="Stop the assistant"
title={status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
>
<Square className="h-3 w-3 fill-current" />
Stop
</button>
)}
<button
type="button"
onClick={() => setMascotVisible((v) => !v)}
className="absolute right-3 top-3 rounded-md bg-black/50 px-2 py-1 text-xs text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
>
{mascotVisible ? 'Hide mascot' : 'Show mascot'}
</button>
</div>
</div>
{/* Captions */}
<div className="flex h-14 items-center justify-center px-6">
{caption && (
<div className="max-w-3xl truncate rounded-lg bg-black/60 px-4 py-2 text-sm text-white/90">
<span className="mr-2 font-semibold text-white">{caption.who}:</span>
{caption.text}
</div>
)}
</div>
{/* Control bar */}
<div className="flex items-center justify-center gap-4 pb-5">
<span className="flex items-center gap-2 rounded-full bg-neutral-800 px-3 py-1.5 text-xs font-medium text-white/90">
{/* Muted overrides "Listening" the green pulse would be a lie.
Thinking/speaking still show: output continues while muted. */}
{micMuted && status === 'listening' ? (
<>
<span className="block h-2 w-2 rounded-full bg-red-500" />
Muted
</>
) : (
<>
<span className={cn('block h-2 w-2 rounded-full', STATUS_DISPLAY[status].dotClass)} />
{STATUS_DISPLAY[status].label}
</>
)}
</span>
<button
type="button"
onClick={onToggleMic}
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
micMuted
? 'bg-red-600 text-white hover:bg-red-500'
: 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
)}
aria-label={micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
title={micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
>
{micMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
<button
type="button"
onClick={onToggleCamera}
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
cameraOn
? 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
: 'bg-red-600 text-white hover:bg-red-500'
)}
aria-label={cameraOn ? 'Turn off camera' : 'Turn on camera'}
title={cameraOn ? 'Turn off camera' : 'Turn on camera'}
>
{cameraOn ? <Video className="h-5 w-5" /> : <VideoOff className="h-5 w-5" />}
</button>
<button
type="button"
onClick={onToggleScreenShare}
className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-white/90 transition-colors hover:bg-neutral-700"
aria-label="Present your screen"
title="Present your screen"
>
<MonitorUp className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => setMascotVisible((v) => !v)}
className="relative flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-white/90 transition-colors hover:bg-neutral-700"
aria-label={mascotVisible ? 'Hide mascot' : 'Show mascot'}
>
<MascotFaceIcon />
{!mascotVisible && (
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="block h-[1.5px] w-6 -rotate-45 rounded-full bg-white/80" />
</span>
)}
</button>
<button
type="button"
onClick={onLeave}
className="flex h-10 w-14 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-500"
aria-label="End call"
>
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)
}

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