mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
Compare commits
No commits in common. "main" and "v0.4.2" have entirely different histories.
579 changed files with 10412 additions and 99877 deletions
36
.github/workflows/electron-build.yml
vendored
36
.github/workflows/electron-build.yml
vendored
|
|
@ -16,14 +16,14 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -111,7 +111,6 @@ jobs:
|
|||
with:
|
||||
name: distributables
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
|
|
@ -122,14 +121,14 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -163,25 +162,12 @@ 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 }}
|
||||
ROWBOAT_SKIP_PACMAN: '1' # Arch Linux package is built locally only, never in CI
|
||||
run: npx electron-forge publish --arch=x64 --platform=linux
|
||||
run: npx electron-forge publish --arch=x64,arm64 --platform=linux
|
||||
working-directory: apps/x/apps/main
|
||||
|
||||
- name: Upload workflow artifacts
|
||||
|
|
@ -189,7 +175,6 @@ jobs:
|
|||
with:
|
||||
name: distributables-linux
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
|
|
@ -200,14 +185,14 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -256,5 +241,4 @@ jobs:
|
|||
with:
|
||||
name: distributables-windows
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
|
|
|||
47
.github/workflows/rowboat-build.yml
vendored
Normal file
47
.github/workflows/rowboat-build.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: Rowboat Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-rowboat-nextjs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
cache-dependency-path: 'apps/rowboat/package-lock.json'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: apps/rowboat
|
||||
|
||||
- name: Build Rowboat
|
||||
run: npm run build
|
||||
working-directory: apps/rowboat
|
||||
|
||||
build-rowboatx:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
cache-dependency-path: 'apps/rowboat/package-lock.json'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: apps/cli
|
||||
|
||||
- name: Build Rowboat
|
||||
run: npm run build
|
||||
working-directory: apps/cli
|
||||
43
.github/workflows/x-publish.yml
vendored
Normal file
43
.github/workflows/x-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Publish to npm
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
working-directory: apps/cli
|
||||
|
||||
# optional: run tests
|
||||
# - run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: apps/cli
|
||||
|
||||
- name: Pack
|
||||
run: npm pack
|
||||
working-directory: apps/cli
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
working-directory: apps/cli
|
||||
69
.github/workflows/x-tests.yml
vendored
69
.github/workflows/x-tests.yml
vendored
|
|
@ -1,69 +0,0 @@
|
|||
name: rowboat
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
apps-x-vitest:
|
||||
name: apps/x Vitest suites
|
||||
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 apps/x Vitest suites
|
||||
run: npm test
|
||||
|
||||
# Typechecks with the dev tsconfigs, which include test files —
|
||||
# the build tsconfigs exclude them and vitest strips types without
|
||||
# checking, so this is the only gate that sees type errors in tests.
|
||||
- name: Typecheck apps/x packages
|
||||
run: npm run typecheck
|
||||
|
||||
apps-x-electron-package:
|
||||
name: apps/x Electron package smoke 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: 24.15.0
|
||||
cache: pnpm
|
||||
cache-dependency-path: apps/x/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Electron app without signing
|
||||
env:
|
||||
ROWBOAT_SKIP_CODE_SIGNING: "1"
|
||||
run: npm run package
|
||||
working-directory: apps/x/apps/main
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,3 @@
|
|||
data/
|
||||
.venv/
|
||||
.claude/
|
||||
|
||||
# Local-only meeting-prep planning doc
|
||||
/PLAN.md
|
||||
|
|
|
|||
|
|
@ -109,9 +109,7 @@ 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 | `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
@ -139,7 +137,6 @@ Long-form docs for specific features. Read the relevant file before making chang
|
|||
### Verify compilation
|
||||
```bash
|
||||
cd apps/x && npm run deps && npm run lint
|
||||
cd apps/x && npm run typecheck # dev tsconfigs — the only gate that typechecks *.test.ts
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
|
|
|||
143
README.md
143
README.md
|
|
@ -1,12 +1,9 @@
|
|||
<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="assets/readme-dark/hero-video.png" />
|
||||
<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"/>
|
||||
|
|
@ -28,105 +25,28 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
# Rowboat
|
||||
**Open-source AI coworker that turns work into a knowledge graph and acts on it**
|
||||
|
||||
</h5>
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 (it’s 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="800" height="450" alt="Rowboat Apps to Code demo" src="apps/x/demo.gif" />
|
||||
</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.
|
||||
|
||||
---
|
||||
## Overview
|
||||
## Demo
|
||||
[](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="1502" height="939" alt="Brain graph screenshot" src="assets/readme-dark/brain.png" />
|
||||
</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="assets/readme-dark/email.png" />
|
||||
</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="assets/readme-dark/background-agents.png" />
|
||||
|
||||
</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 it's 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="assets/readme-dark/browser.png" />
|
||||
</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="assets/readme-dark/meeting-notes.png" />
|
||||
</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="assets/readme-dark/code-mode.png" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Apps</h3>
|
||||
You can build your own work surfaces inside Rowboat — they get access 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="assets/readme-dark/apps.png" />
|
||||
</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="assets/readme-dark/integrations.png" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -161,6 +81,23 @@ 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 don’t want to re-explain (people, projects, decisions, commitments)
|
||||
- **Understand** what’s 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 it’s different
|
||||
|
||||
|
|
@ -174,6 +111,24 @@ 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:
|
||||
|
|
|
|||
|
|
@ -4,13 +4,7 @@ import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/securit
|
|||
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
// Order matters: longer separators (`||`, `&&`) must precede their single-char
|
||||
// prefixes (`|`, `&`) so the leftmost-longest match consumes the right token.
|
||||
// `&` (background), backtick / `$(` (command substitution), and `(` `)`
|
||||
// (subshell) are also command separators — without them, `echo hi & rm /x`,
|
||||
// `echo \`rm /x\``, and `echo $(rm /x)` slip past isBlocked() with only
|
||||
// `echo` in the allowlist.
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|&|;|\||\n|`|\$\(|\(|\))/;
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||
const EXECUTION_SHELL = getExecutionShell();
|
||||
|
|
|
|||
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -16,15 +16,13 @@
|
|||
|
||||
## Event catalog
|
||||
|
||||
All PostHog events include `app_version` and `platform: 'desktop'` automatically. Main-process events add them in `packages/core/src/analytics/posthog.ts`; renderer events get them from the `analytics:bootstrap` IPC payload via `posthog.register` (plus an initialization-time `before_send` hook for `app_version`). `platform` guards against the legacy web dashboard's autocapture (`apps/rowboat`, unidentified by design) muddying desktop dashboards if it ever shares the project.
|
||||
|
||||
### `llm_usage`
|
||||
|
||||
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` / `code_session` |
|
||||
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
|
||||
| `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` |
|
||||
|
|
@ -41,9 +39,9 @@ Every `llm_usage` emit point in the codebase:
|
|||
|
||||
| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line |
|
||||
|---|---|---|---|---|
|
||||
| `copilot_chat` | (none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | `packages/core/src/runtime/turns/bridges/real-usage-reporter.ts` (`reportModelUsage`); legacy runs (code-mode carve-out) still emit from `packages/core/src/runtime/legacy/engine.ts` (`streamLlm` finish-step) |
|
||||
| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) |
|
||||
| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` |
|
||||
| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/runtime/tools/domains/parsing.ts:179` |
|
||||
| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` |
|
||||
| `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` |
|
||||
| `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) |
|
||||
| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site |
|
||||
|
|
@ -53,10 +51,10 @@ Every `llm_usage` emit point in the codebase:
|
|||
| `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) |
|
||||
| `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) |
|
||||
| `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) |
|
||||
| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) |
|
||||
| `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,106 +89,9 @@ 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 }`
|
||||
|
||||
### Client auto-update funnel
|
||||
|
||||
The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`:
|
||||
|
||||
- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the "Update available" card was shown for a staged update
|
||||
- `update_restarted` — main (`apps/main/src/updater.ts`), `{ from, to? }`: the user clicked restart-to-update (`to` may be missing when the update feed doesn't report the release name)
|
||||
- `update_failed` — main (`apps/main/src/updater.ts`), `{ message }`: the auto-updater errored (includes network errors for now)
|
||||
- `client_updated` — main (`apps/main/src/ipc.ts`), `{ from, to }`: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire)
|
||||
### `view_opened` — feature-importance funnel
|
||||
|
||||
One event per view the user lands on, fired centrally from the `currentViewState` effect in `apps/renderer/src/App.tsx`. `view` is one of: `chat`, `file`, `graph`, `task`, `suggested-topics`, `meetings`, `live-notes`, `email`, `workspace`, `knowledge-view`, `chat-history`, `home`, `code`, `bg-tasks`, `apps`. Keyed on the view *type*, so switching files or threads inside a view doesn't re-fire.
|
||||
|
||||
This is the top of every feature funnel: unique users on `view = 'email'` ÷ all users = how many people even open email. First visit to a key view also sets a one-shot person property (`has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code`) for cohort building.
|
||||
|
||||
### Feature action events
|
||||
|
||||
All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers); the emit sites are in the components named below. Events marked **(main)** are captured in `apps/main/src/ipc.ts` via `capture()` because the operation runs there.
|
||||
|
||||
**Email** (`components/email-view.tsx`):
|
||||
|
||||
- `email_thread_opened` — a thread was expanded in the list
|
||||
- `email_compose_opened` — `{ mode: 'new' | 'reply' | 'replyAll' | 'forward' | 'draft' }` — a composer was opened
|
||||
- `email_sent` — `{ mode, has_attachments, ai_assisted }` — `ai_assisted` is true when Write-with-AI produced a draft in that composer
|
||||
- `email_ai_draft_generated` — `{ mode: 'generate' | 'rewrite' }` — the Write/Edit-with-AI bar completed
|
||||
- `email_archived` / `email_trashed` — one thread archived / moved to trash
|
||||
- `email_marked_unread` — explicit mark-as-unread (marking *read* fires automatically on open, so it's deliberately not tracked)
|
||||
- `email_importance_changed` — `{ importance: 'important' | 'other' }` — user corrected the importance verdict
|
||||
- `email_category_changed` — `{ category }` — user re-filed a thread
|
||||
- `email_category_archived` — `{ category }` — bulk "archive all in category"
|
||||
- `email_searched` — a search query executed (debounced, one per settled query)
|
||||
- `email_instructions_saved` — standing email-agent instructions saved
|
||||
- `email_sync_triggered` — manual refresh button
|
||||
|
||||
**Meetings** (`App.tsx`, `components/meetings-view.tsx`):
|
||||
|
||||
- `meeting_recording_started` — `{ has_calendar_event }` — transcription actually began (all entry points: meetings view, home, sidebar, popup funnel through one call site)
|
||||
- `meeting_recording_stopped` — `{ duration_seconds }`
|
||||
- `meeting_popup_action` — `{ action: 'take-notes' | 'dismiss' }` **(main)** — the "meeting detected" popup window runs without PostHog, so the action is captured in its IPC handler
|
||||
- `meeting_note_opened` — a past meeting note opened from the meetings list
|
||||
|
||||
**Calls** (`App.tsx`):
|
||||
|
||||
- `call_started` — (pre-existing, above) fires on every call-button press that starts a call
|
||||
- `call_ended` — `{ duration_seconds }`
|
||||
|
||||
**Background agents** (`components/bg-tasks-view.tsx`, `components/apps/app-detail.tsx`):
|
||||
|
||||
- `bg_agent_created` — `{ method: 'manual' | 'coding' | 'copilot', has_triggers }` — `copilot` means the user submitted the "describe it" form (the agent is then created by Copilot in chat)
|
||||
- `bg_agent_updated` — instructions/triggers/model saved on an existing agent
|
||||
- `bg_agent_toggled` — `{ active }`
|
||||
- `bg_agent_run_clicked` — manual Run now
|
||||
- `bg_agent_stopped` — manual stop of a run
|
||||
- `bg_agent_deleted`
|
||||
|
||||
**Live notes** (`components/live-note-sidebar.tsx`, `components/live-notes-view.tsx`):
|
||||
|
||||
- `live_note_saved` — live config created or edited via the panel
|
||||
- `live_note_toggled` — `{ active }`
|
||||
- `live_note_run_clicked` — manual Run
|
||||
- `live_note_stopped` — in-flight run stopped
|
||||
- `live_note_deleted` — live config removed from the note
|
||||
- `live_note_edit_with_copilot_clicked`
|
||||
|
||||
**Search** (`components/search-dialog.tsx`):
|
||||
|
||||
- `search_opened` — the palette opened
|
||||
- `search_executed` — (pre-existing, above)
|
||||
- `search_result_selected` — `{ type: 'knowledge' | 'chat' }`
|
||||
|
||||
**Apps** — all **(main)**, in `apps/main/src/ipc.ts` (pre-existing except `app_rolled_back`): `app_created`, `app_installed`, `app_uninstalled`, `app_updated`, `app_rolled_back`, `app_published`, `app_starred`, `app_deleted`. Plus renderer-side `app_opened` — `{ folder }` — an installed app's UI was opened (`components/apps/app-frame.tsx`).
|
||||
|
||||
**Code mode** — both **(main/core)**:
|
||||
|
||||
- `code_session_created` — `{ mode: 'direct' | 'rowboat', agent }` — captured in the `codeSession:create` IPC handler. This is the direct-vs-rowboat session split.
|
||||
- `code_session_message_sent` — `{ mode, agent }` — one per direct-drive message (`packages/core/src/code-mode/sessions/service.ts`). Direct turns bypass the agent runtime and emit no `llm_usage`, so this is the only usage-depth signal for direct mode; Rowboat-mode depth comes from `llm_usage where use_case = code_session`.
|
||||
|
||||
**Billing** (`components/billing-error-dialog.tsx`):
|
||||
|
||||
- `billing_error_shown` — `{ kind: 'subscription_required' | 'out_of_credits' | 'subscription_inactive' }` — the paywall dialog appeared
|
||||
- `billing_upgrade_clicked` — `{ kind }` — the upgrade CTA was clicked (shown → clicked = paywall conversion)
|
||||
|
||||
**Failures** — success events all have a failure sibling where the operation can fail after the click:
|
||||
|
||||
- `email_send_failed` — send returned an error or threw (`components/email-view.tsx`)
|
||||
- `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`)
|
||||
- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks
|
||||
|
||||
**Misc**:
|
||||
|
||||
- `note_created` — new note from the sidebar/knowledge actions (`App.tsx`)
|
||||
- `note_edited` — a note's autosave wrote changed content; deduped to one event per note per app session (so it counts "notes touched", not keystroke bursts)
|
||||
- `settings_opened` — `{ tab }` — settings dialog opened (tab = the initial tab)
|
||||
- `settings_tab_changed` — `{ tab }`
|
||||
- `onboarding_completed` — the onboarding flow finished (`App.tsx`)
|
||||
|
||||
## Person properties
|
||||
|
||||
Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`.
|
||||
|
|
@ -200,14 +101,10 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as
|
|||
| `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations |
|
||||
| `plan`, `status` | main on identify | Subscription state |
|
||||
| `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production |
|
||||
| `platform` | both processes (init + identify) | Always `desktop` from this app; segments desktop users from any other surface |
|
||||
| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event |
|
||||
| `signed_in` | renderer | `true` while rowboat OAuth is connected |
|
||||
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
|
||||
| `total_notes` | renderer (init) | Workspace size signal |
|
||||
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
|
||||
| `has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code` | renderer (`view_opened`) | One-shot first-use flags per feature view |
|
||||
| `has_created_bg_agent` | renderer | One-shot: user set up a background agent |
|
||||
|
||||
## How to add a new event
|
||||
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# Granola Parity — Research & Gap Analysis
|
||||
|
||||
Goal: make Rowboat's meeting feature work exactly like Granola (granola.ai). We copy Granola's behavior — no new invention. This doc is the source of truth: how Granola works, what Rowboat has today (with file:line pointers), the gaps, and the parity plan.
|
||||
|
||||
Research basis: Granola's official docs (docs.granola.ai), granola.ai blog/security/jobs pages, third-party reviews and reverse-engineering writeups, plus a full audit of this codebase. Claims that are inferred (not directly documented by Granola) are marked **[inference]**.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — How Granola works
|
||||
|
||||
Granola is an Electron app (confirmed by their own job postings: "Granola is an Electron app with deep OS integrations", React/TypeScript UI + native OS helpers). No bot ever joins the call — it captures audio locally on the device, so nothing is visible to other meeting participants.
|
||||
|
||||
### 1.1 Always-running app (launch at login)
|
||||
|
||||
- The product model is an **always-running, menu-bar-resident app**. Granola must be running for detection/notifications to work ("You must have Granola open... for transcription to run" — their troubleshooting docs).
|
||||
- A literal "open at login" toggle is **not publicly documented**. **[inference]** A clone should register as a login item (Electron `app.setLoginItemSettings`), default on, with a setting to turn it off — the entire detection value prop collapses if the app isn't running.
|
||||
- The app keeps running when the window closes (menu bar presence remains).
|
||||
|
||||
### 1.2 Menu bar icon
|
||||
|
||||
- Granola "sits in your Mac's menu bar". Documented affordance: **click the menu bar icon to start recording / open the app**.
|
||||
- Recording status is NOT primarily shown in the menu bar. The documented indicators are:
|
||||
- Inside the note: "green dancing bars" at the bottom while capturing.
|
||||
- When another app has focus: a **floating always-on-top "live meeting" pill** on the right side of the screen.
|
||||
- Exact dropdown contents (upcoming meetings list, icon state change while recording) are not documented. The "Coming up" list lives on the home screen, not the tray.
|
||||
|
||||
### 1.3 Meeting detection — two signals, never auto-record
|
||||
|
||||
Granola detects meetings via **calendar events + microphone-in-use detection**, and it **never records without a user action** ("Granola only starts transcribing when you open a note for that meeting").
|
||||
|
||||
**Signal A — Calendar:**
|
||||
- Google / Microsoft calendar sync. Pre-creates an auto-titled note per event with 2+ attendees; filters declined events, OOO, Focus Time.
|
||||
- **In-app popup (Granola-drawn, not macOS Notification Center) 1 minute before** any scheduled call with 2+ attendees. One click on it **opens the meeting URL AND starts transcription** at once.
|
||||
- **Armed auto-start:** if you have the upcoming meeting's note open before it starts, recording starts automatically at the scheduled time.
|
||||
|
||||
**Signal B — Mic-in-use (ad-hoc calls, incl. browser meetings):**
|
||||
- "Granola notices when your microphone is in use and offers to start taking notes."
|
||||
- It's app-aware: notification title is "**Huddle detected**" (Slack), "**Call detected**" (FaceTime/WhatsApp), "**Meeting detected**" (anything else, including browsers). Buttons: "Take Notes". Ad-hoc popups have a dashed left border; calendar ones a solid colored bar.
|
||||
- If the ad-hoc call starts within **15 minutes** of a scheduled event, the popup adopts that event's name (merged).
|
||||
- **[inference]** Mechanism: poll CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` on input devices for "mic in use", plus enumerate running processes against a known meeting-app list to get the app-specific title. There's no public macOS API for "process X is using the mic", so it's a heuristic combo. Browser meetings degrade to the generic "Meeting detected" title; calendar linkage supplies identity.
|
||||
- Notifications are configurable in Settings (off entirely, or per-application).
|
||||
- Related preference: "**Reposition Granola for meetings**" — window auto-repositions/resizes alongside the call when a meeting starts (toggleable, default on).
|
||||
|
||||
### 1.4 Audio capture
|
||||
|
||||
- Capture = **default microphone + system audio output**, at the OS level. Works with any app or browser (Chrome/Safari/Firefox/Edge) with no extension.
|
||||
- Cannot isolate per-app audio — it's the combined system stream; uses the OS default sound devices.
|
||||
- The two streams stay separate through transcription → transcript UI shows **grey bubbles (left) = system audio ("Them"), green bubbles (right) = your mic ("Me")**. No true live diarization on desktop — just Me/Them.
|
||||
- **macOS permissions: exactly two** — Microphone, and Screen & System Audio Recording (macOS bundles system-audio capture under screen recording). No Accessibility permission.
|
||||
- **[inference]** System audio mechanism: ScreenCaptureKit audio loopback (macOS 13+ baseline, they require 13+ / recommend 14.2+), possibly CoreAudio process taps on 14.4+. No virtual audio driver (setup has no driver install step).
|
||||
- **Audio is never stored** — streamed to the ASR provider in real time and discarded.
|
||||
|
||||
### 1.5 Transcription
|
||||
|
||||
- Cloud, real-time streaming ASR. Subprocessors named on their security page: **Deepgram and AssemblyAI** for ASR; **OpenAI and Anthropic** for note enhancement.
|
||||
- Live transcript accrues during the call, hidden behind a waveform-icon toggle in the note.
|
||||
|
||||
### 1.6 Meeting end & post-meeting flow
|
||||
|
||||
- **Auto-stop conditions** (documented): (a) call-end heuristic — transcript inactivity + whether the call software is still in use + scheduled end time for calendar-linked meetings; (b) **15 minutes of silence**; (c) computer sleeps; (d) manual stop. Note: "on macOS, meeting auto-end detection requires admin rights" — without it, manual stop only.
|
||||
- **On stop, enhancement runs automatically** — no click needed. Enhanced notes are ready in seconds (~30s max reported).
|
||||
- A "**notes ready**" notification fires when enhancement completes — that's the re-entry point back into the app if you switched away. The note itself was already open (recording ran inside it), so the enhanced version replaces the raw view in place. There's no documented "force-focus the window" behavior; the notification does the redirecting.
|
||||
- Extras: auto-drafted follow-up emails (toggleable), pre-meeting briefs.
|
||||
|
||||
### 1.7 Notes UI
|
||||
|
||||
**During the meeting:** a plain notepad — the user types rough bullets; transcription runs invisibly. Green dancing bars at the bottom = capturing. Waveform button (next to the per-note "Ask anything" chat bar) opens the live transcript side-by-side. Mid-meeting you can ask the chat to catch you up.
|
||||
|
||||
**After the meeting (enhanced notes):**
|
||||
- Enhancement merges exactly three inputs: **transcript + your raw notes + calendar metadata**, through a template.
|
||||
- Signature affordance: **your words render black, AI-generated text grey**.
|
||||
- Per-line provenance: magnifying-glass icon on a line reveals where it came from in the transcript.
|
||||
- Templates: built-ins (1:1, standup, sales discovery) + custom; re-enhance with a different template (✨), regenerate (🔁), edit raw notes and re-enhance.
|
||||
- Chat: per-note "Ask anything" bar, global chat (⌘J), cross-meeting chat over folders; edit-by-asking.
|
||||
- Home screen: "**Coming up**" strip (next ~5 meetings) + reverse-chronological past notes list; folders; recurring meetings grouped by recurring-event ID + title; shareable links.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — What Rowboat has today (audit of main)
|
||||
|
||||
Two separate feature families exist in `apps/x`; only the second is Granola-adjacent:
|
||||
- **"Calls" (video mode)** — live voice/video chat *with the Rowboat AI* (`VIDEO_MODE.md`). Not meeting capture.
|
||||
- **"Meetings"** — real meeting capture → transcript → AI notes. Working pipeline, but **calendar-triggered and manual-click only**.
|
||||
|
||||
### What EXISTS (and is solid)
|
||||
|
||||
| Area | Status | Where |
|
||||
|---|---|---|
|
||||
| Mic + system-audio capture, 2-channel | ✅ | `apps/renderer/src/hooks/useMeetingTranscription.ts:282-299` — mic `getUserMedia` (ch 0) + `getDisplayMedia({audio})` loopback (ch 1), merged to 16 kHz PCM (`:424-485`); loopback auto-approved in `apps/main/src/main.ts:216-226` |
|
||||
| Realtime ASR | ✅ | Deepgram realtime WS, `nova-3`, multichannel + diarize (`useMeetingTranscription.ts:9-21`); proxy or raw key (`:253-280`) |
|
||||
| Speaker labels | ✅ | ch 0 → "You", ch 1 → diarized `Speaker N` (`:335-347`) |
|
||||
| Transcript storage | ✅ | Markdown + frontmatter + fenced transcript block, `knowledge/Meetings/rowboat/<date>/<name>.md` (`:93-136`, `:487-510`) |
|
||||
| AI meeting notes on stop | ✅ | `packages/core/src/knowledge/summarize_meeting.ts` — LLM summary, attendee-name resolution from calendar; orchestrated in `App.tsx:5601-5661`; notes prepended above transcript |
|
||||
| Auto-stop heuristics | ✅ (partial) | silence RMS backstop, calendar-end gating, system-track ended/muted — `useMeetingTranscription.ts:378-549` |
|
||||
| Calendar sync | ✅ | Google OAuth via `googleapis`, `packages/core/src/knowledge/sync_calendar.ts`, per-event JSON in `~/.rowboat/calendar_sync/` |
|
||||
| Pre-meeting notification | ✅ (system toast) | `packages/core/src/knowledge/notify_calendar_meetings.ts` — polls every 30s, notifies ~1 min before, deep-links `rowboat://action?type=join-and-take-meeting-notes` |
|
||||
| Meetings screen | ✅ | `apps/renderer/src/components/meetings-view.tsx` — "Coming up" (+Join / Take-notes buttons, inline prep) + past-notes table |
|
||||
| Transcript rendering | ✅ | `apps/renderer/src/extensions/transcript-block.tsx` (TipTap, colored speakers, collapsible) |
|
||||
| Meeting prep briefs | ✅ | `meeting_prep_scheduler.ts`, `meeting_prep_brief.ts` (Granola has this too) |
|
||||
| Permissions flows | ✅ | mic `ipc.ts:2090-2106`; screen recording check/open-settings `ipc.ts:2003-2018`; Info.plist strings `forge.config.cjs:201-204`; entitlements OK |
|
||||
|
||||
### What is MISSING (the entire "ambient" layer)
|
||||
|
||||
| # | Gap | Detail |
|
||||
|---|---|---|
|
||||
| 1 | **Launch at login** | Zero `setLoginItemSettings` / auto-launch code anywhere. |
|
||||
| 2 | **Menu bar (tray) icon** | Zero `Tray` usage. No background presence: on macOS closing the window leaves only the Dock; no way to start recording without the full window. |
|
||||
| 3 | **Meeting detection** | No mic-in-use detection, no process detection (Zoom/Teams/Slack/FaceTime), no browser awareness. Only calendar (time-based). Ad-hoc calls are invisible. |
|
||||
| 4 | **Auto/one-click start UX** | Capture starts only via explicit button click or clicking the calendar toast. No "Meeting detected — Take notes?" popup, no armed auto-start when a note is open at meeting start time. |
|
||||
| 5 | **Headless capture** | Capture depends on the renderer window holding a live `getDisplayMedia` stream. No native audio helper → can't capture while the app is "in the background" the way Granola does. |
|
||||
| 6 | **Meeting-end redirect** | On stop, the note refreshes in place, but there's **no "notes ready" notification** and no bring-the-user-back moment if they're in another app. |
|
||||
| 7 | **Notes UI polish (Granola signatures)** | No black-vs-grey authorship distinction (we replace, they merge raw notes + transcript), no during-meeting notepad-first flow (we show the transcript note), no per-line provenance, no templates/re-enhance, no floating "recording" pill. |
|
||||
|
||||
### Honest assessment
|
||||
|
||||
Rowboat has built the *second half* of Granola well — what happens once you're recording, and after the meeting ends. It has essentially none of the *first half* — noticing a meeting is happening and quietly being there without the user opening the app. That first half is exactly items 1–5 above, and it's where all the work is.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Parity plan (copy Granola, no new stuff)
|
||||
|
||||
Ordered so each phase is shippable and testable on its own.
|
||||
|
||||
### Phase 1 — Resident app: login item + tray
|
||||
- `app.setLoginItemSettings({ openAtLogin: true })`, default on, toggle in Settings. When launched at login: no window, tray only **[inference — Granola undocumented, but implied]**.
|
||||
- Electron `Tray` with template icon; menu: "Start recording", "Open Rowboat", recording status line, Quit. Keep app alive on window close (already macOS default; add tray so it's reachable).
|
||||
- Acceptance: reboot → icon in menu bar, no window; click tray → start an ad-hoc meeting note.
|
||||
|
||||
### Phase 2 — Meeting detection + "Take notes?" popup
|
||||
- Native signal (small Swift helper or node addon, polled from main): mic-in-use via CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` + running-process match against a known list (zoom.us, Teams, Slack, FaceTime, browsers…).
|
||||
- Granola-style app-drawn popup (small always-on-top BrowserWindow, like the existing video popout): "Huddle detected / Call detected / Meeting detected — [Take Notes]". Merge with a calendar event if within 15 min. Never auto-record.
|
||||
- Upgrade the existing 1-min-before calendar toast to the same popup style; one click = open meeting URL + start capture (deep-link plumbing already exists in `deeplink.ts`).
|
||||
- Armed auto-start: meeting note open before start time → auto-start at start time.
|
||||
- Per-app notification settings.
|
||||
- Acceptance: start a Meet call in Chrome with no calendar event → popup appears; click → recording.
|
||||
|
||||
### Phase 3 — Meeting end → notes ready redirect
|
||||
- Keep existing auto-stop heuristics; add the missing "call software no longer active" signal from the Phase-2 process watcher; keep 15-min silence backstop (ours is stricter — align to Granola's 15 min).
|
||||
- On summary completion: fire a **"Your meeting notes are ready" notification**; clicking focuses the app on the note (deep link exists). This is the "redirect when we cut the call" the feature needs.
|
||||
- Acceptance: leave the call → recording stops on its own → notification within ~30s → click lands on finished notes.
|
||||
|
||||
### Phase 4 — Notes UI parity
|
||||
- During meeting: notepad-first note (user types; transcript behind a waveform toggle); green capture indicator in-note; floating "live meeting" pill when app unfocused (reuse the video-popout window machinery).
|
||||
- After meeting: enhancement merges **raw notes + transcript + calendar event** (today we only use the transcript); render user text black / AI text grey; ✨ re-enhance + 🔁 regenerate; keep transcript below as today.
|
||||
- Home: Meetings view already ≈ Granola's home (Coming up + past list) — minor polish only.
|
||||
|
||||
### Phase 5 (only if needed for true parity) — Headless capture
|
||||
- Native mic + ScreenCaptureKit system-audio capture in main/helper process so recording doesn't require the renderer window. Biggest lift; Phases 1–4 deliver the Granola experience with the window opening on record start, which is acceptable Granola-like behavior (their note opens on start too).
|
||||
|
||||
### Key implementation notes
|
||||
- Permissions stay exactly two (mic + screen-recording) — we already handle both.
|
||||
- Deepgram nova-3 multichannel already matches Granola's Me/Them model — no ASR change needed.
|
||||
- Reuse: popup ← video popout window pattern; deep links ← `deeplink.ts`; detection loop ← same main-process init pattern as `notify_calendar_meetings.ts`.
|
||||
|
|
@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t
|
|||
Two paths, both producing identical on-disk YAML:
|
||||
|
||||
1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick.
|
||||
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
|
||||
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
|
||||
|
||||
When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block.
|
||||
|
||||
|
|
@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message:
|
|||
- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it").
|
||||
|
||||
The agent's system prompt tells it to:
|
||||
1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
|
||||
2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites.
|
||||
1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
|
||||
2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites.
|
||||
3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first.
|
||||
4. Never modify YAML frontmatter — that's owned by the user and the runtime.
|
||||
5. End with a 1-2 sentence summary stored as `lastRunSummary`.
|
||||
|
|
@ -115,7 +115,7 @@ Backend (main process)
|
|||
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
|
||||
└─ Builtin tool │ │
|
||||
run-live-note-agent ────┘ ▼
|
||||
file-readText / -edit
|
||||
workspace-readFile / -edit
|
||||
│
|
||||
▼
|
||||
body region(s) rewritten on disk
|
||||
|
|
@ -175,7 +175,7 @@ Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' |
|
|||
|
||||
`buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context).
|
||||
|
||||
This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
|
||||
This lets the user-authored objective branch on trigger kind when warranted (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
|
||||
|
||||
### Run flow (`runLiveNoteAgent`)
|
||||
|
||||
|
|
@ -249,20 +249,22 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know
|
|||
- Then content organized by sub-topic under H2 headings, freshest/most-important first.
|
||||
- Tightness over decoration.
|
||||
- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly.
|
||||
- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
|
||||
- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
|
||||
- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1.
|
||||
|
||||
---
|
||||
|
||||
## Default Note Policy
|
||||
## Daily-Note Template & Migrations
|
||||
|
||||
Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block.
|
||||
`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals.
|
||||
|
||||
**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start:
|
||||
**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`:
|
||||
|
||||
- File missing → mark processed and do nothing.
|
||||
- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body.
|
||||
- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again.
|
||||
- File missing → fresh write at canonical version.
|
||||
- File at-or-above canonical → no-op.
|
||||
- File below canonical → rename existing to `Today.md.bkp.<ISO-stamp>` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body).
|
||||
|
||||
The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -316,7 +318,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd
|
|||
- **Purpose**: the user message seeded into each agent run.
|
||||
- **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`).
|
||||
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`.
|
||||
- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
|
||||
- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
|
||||
|
||||
Three branches by `trigger`:
|
||||
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills.
|
||||
|
|
@ -326,7 +328,7 @@ Three branches by `trigger`:
|
|||
### 5. Live Note skill (Copilot-facing)
|
||||
|
||||
- **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills.
|
||||
- **File**: `packages/core/src/runtime/assembly/skills/live-note/skill.ts`. Exported `skill` constant.
|
||||
- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant.
|
||||
- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` is interpolated into the "Canonical Schema" section. Edits to `LiveNoteSchema` propagate automatically.
|
||||
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('live-note')` fires.
|
||||
- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`.
|
||||
|
|
@ -334,7 +336,7 @@ Three branches by `trigger`:
|
|||
### 6. Copilot trigger paragraph
|
||||
|
||||
- **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded.
|
||||
- **File**: `packages/core/src/runtime/assembly/copilot/instructions.ts` (look for the "Live Notes" paragraph).
|
||||
- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live Notes" paragraph).
|
||||
- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…").
|
||||
- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up.
|
||||
- **Anti-signals (do NOT make live)**: definitional questions, one-off lookups, manual document editing.
|
||||
|
|
@ -343,7 +345,7 @@ Three branches by `trigger`:
|
|||
### 7. `run-live-note-agent` tool — `context` parameter description
|
||||
|
||||
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run.
|
||||
- **File**: `packages/core/src/runtime/tools/catalog.ts` (the `run-live-note-agent` tool definition).
|
||||
- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-live-note-agent` tool definition).
|
||||
- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), optional `context`.
|
||||
- **Output**: flows into `runLiveNoteAgent(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message.
|
||||
- **Key use case**: backfill a newly-made-live note so its body isn't empty on day 1.
|
||||
|
|
@ -391,13 +393,13 @@ Conventions:
|
|||
| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` |
|
||||
| Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` |
|
||||
| Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` |
|
||||
| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` |
|
||||
| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` |
|
||||
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
|
||||
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
|
||||
| Copilot skill | `packages/core/src/runtime/assembly/skills/live-note/skill.ts` |
|
||||
| Skill registration | `packages/core/src/runtime/assembly/skills/index.ts` |
|
||||
| Copilot trigger paragraph | `packages/core/src/runtime/assembly/copilot/instructions.ts` |
|
||||
| `run-live-note-agent` builtin tool | `packages/core/src/runtime/tools/catalog.ts` |
|
||||
| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |
|
||||
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
|
||||
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
|
||||
| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` |
|
||||
| Editor toolbar (Radio button → panel) | `apps/renderer/src/components/editor-toolbar.tsx` |
|
||||
| Live Note panel (single-view editor) | `apps/renderer/src/components/live-note-sidebar.tsx` |
|
||||
| Status hook (`useLiveNoteAgentStatus`) | `apps/renderer/src/hooks/use-live-note-agent-status.ts` |
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
# 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/runtime/assembly/message-encoding.ts` `convertFromMessages`:
|
||||
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/runtime/assembly/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `runtime/assembly/compose-instructions.ts`) |
|
||||
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) |
|
||||
| "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 (`runtime/assembly/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/runtime/assembly/message-encoding.ts` (`convertFromMessages`) |
|
||||
| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/runtime/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 ≈ 250–350 tokens each (≤12/message ≈ 3–4k); screen frames ≈
|
||||
1.5–2k tokens each (≤4/message ≈ 6–8k). 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.
|
||||
|
|
@ -10,17 +10,11 @@
|
|||
*/
|
||||
|
||||
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,
|
||||
// and we use define to replace all import.meta.url references with it.
|
||||
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
||||
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['./dist/main.js'],
|
||||
|
|
@ -28,11 +22,7 @@ await esbuild.build({
|
|||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: './.package/dist/main.cjs',
|
||||
// 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'],
|
||||
external: ['electron'], // Provided by Electron runtime
|
||||
// Use CommonJS format - many dependencies use require() which doesn't work
|
||||
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
format: 'cjs',
|
||||
|
|
@ -46,108 +36,7 @@ await esbuild.build({
|
|||
// Empty strings disable analytics gracefully.
|
||||
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
|
||||
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
|
||||
'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
|
||||
},
|
||||
});
|
||||
|
||||
// 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');
|
||||
|
||||
// electron-chrome-extensions injects a preload script into browser tabs to
|
||||
// implement the chrome.* extension APIs. It resolves that file at runtime:
|
||||
// via require.resolve when node_modules is present (dev), falling back to a
|
||||
// file next to the running bundle (packaged app, where node_modules is
|
||||
// gone). Stage it next to main.cjs for the packaged case.
|
||||
const crxPreloadSrc = fs.realpathSync(
|
||||
path.join(here, 'node_modules', 'electron-chrome-extensions', 'dist', 'chrome-extension-api.preload.js'),
|
||||
);
|
||||
fs.copyFileSync(crxPreloadSrc, path.join(here, '.package', 'dist', 'chrome-extension-api.preload.js'));
|
||||
console.log('✅ electron-chrome-extensions preload staged');
|
||||
|
||||
// Compile the mic-monitor helper (ambient meeting detection) on macOS.
|
||||
// Best-effort: without swiftc — or on other platforms — the app still works,
|
||||
// ad-hoc meeting detection just stays off (main checks the binary exists).
|
||||
if (process.platform === 'darwin') {
|
||||
const swiftSrc = path.join(here, 'native', 'mic-monitor.swift');
|
||||
const helperOut = path.join(here, '.package', 'dist', 'mic-monitor');
|
||||
const upToDate = fs.existsSync(helperOut) &&
|
||||
fs.statSync(helperOut).mtimeMs >= fs.statSync(swiftSrc).mtimeMs;
|
||||
if (upToDate) {
|
||||
console.log('✅ mic-monitor helper up to date');
|
||||
} else {
|
||||
try {
|
||||
execSync(`swiftc -O "${swiftSrc}" -o "${helperOut}"`, { stdio: 'inherit' });
|
||||
console.log('✅ mic-monitor helper compiled');
|
||||
} catch {
|
||||
console.warn('⚠️ mic-monitor helper not built (swiftc unavailable?) — meeting detection disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)`);
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
|
|
|
|||
|
|
@ -5,190 +5,6 @@
|
|||
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';
|
||||
const SKIP_CODE_SIGNING = process.env.ROWBOAT_SKIP_CODE_SIGNING === '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',
|
||||
|
|
@ -200,9 +16,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).',
|
||||
},
|
||||
...(SKIP_CODE_SIGNING ? {} : {
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
optionsForFile: () => ({
|
||||
|
|
@ -215,24 +29,17 @@ module.exports = {
|
|||
appleIdPassword: process.env.APPLE_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID
|
||||
},
|
||||
}),
|
||||
// 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.
|
||||
// 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.
|
||||
prune: false,
|
||||
// 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));
|
||||
},
|
||||
ignore: [
|
||||
/src\//,
|
||||
/node_modules\//,
|
||||
/.gitignore/,
|
||||
/bundle\.mjs/,
|
||||
/tsconfig.json/,
|
||||
],
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
|
|
@ -249,17 +56,6 @@ module.exports = {
|
|||
description: 'AI coworker with memory',
|
||||
name: `Rowboat-win32-${arch}`,
|
||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||
// The animation is Squirrel's ONLY install UI — without this
|
||||
// users stare at Squirrel's unbranded default mid-install.
|
||||
loadingGif: path.join(__dirname, 'icons/install-loading.gif'),
|
||||
// Add/Remove Programs icon. Must be a remote URL (Squirrel
|
||||
// limitation); defaults to the Atom feather otherwise.
|
||||
iconUrl: 'https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/x/apps/main/icons/icon.ico',
|
||||
// Skip the machine-wide MSI deployment stub — it lands on the
|
||||
// GitHub release page next to setup.exe and users grab the
|
||||
// wrong one (it neither launches the app nor auto-updates).
|
||||
noMsi: true,
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
@ -270,9 +66,7 @@ module.exports = {
|
|||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -283,28 +77,10 @@ module.exports = {
|
|||
name: `Rowboat-linux`,
|
||||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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"],
|
||||
|
|
@ -397,17 +173,6 @@ 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/');
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-off dev tool, run manually (requires ImageMagick) — NOT part of any
|
||||
# build step. Its output, install-loading.gif, is committed and wired into
|
||||
# forge.config.cjs as Squirrel.Windows' `loadingGif` (the installer's only
|
||||
# UI). Re-run only if the icon or branding changes.
|
||||
#
|
||||
# Generates the Squirrel install animation: icon + title + operation label.
|
||||
# Timeline approximates a typical 20-25s install, then holds at "Almost done"
|
||||
# for the slow-machine tail. Frame every 0.5s. No progress bar — Squirrel gives
|
||||
# no real progress signal, so a bar would just be a fake timeline.
|
||||
set -euo pipefail
|
||||
|
||||
ICON=$(dirname "$0")/icon.png
|
||||
# DejaVu Sans by name on systems that have it installed; set FONT to a
|
||||
# DejaVuSans.ttf path on systems that don't (e.g. Windows/macOS).
|
||||
FONT="${FONT:-DejaVu-Sans}"
|
||||
OUT_DIR=$(mktemp -d)
|
||||
mkdir -p "$OUT_DIR"
|
||||
rm -f "$OUT_DIR"/frame-*.png
|
||||
|
||||
FRAMES=110 # 55s total at 0.5s/frame
|
||||
|
||||
for i in $(seq 0 $((FRAMES - 1))); do
|
||||
label=$(awk -v i="$i" 'BEGIN {
|
||||
t = i * 0.5
|
||||
if (t < 14) print "Installing"; else if (t < 21) print "Creating shortcuts"; else print "Almost done"
|
||||
}')
|
||||
|
||||
# Animated trailing dots (ellipsis), cycling every 2s
|
||||
ndots=$((i % 4))
|
||||
dots=""
|
||||
for _ in $(seq 1 $ndots); do dots=". $dots"; done
|
||||
|
||||
magick -size 480x320 xc:'#252525' \
|
||||
\( "$ICON" -resize 84x84 \) -gravity center -geometry +0-72 -composite \
|
||||
-gravity center -font "$FONT" -pointsize 21 -fill '#e8e8e8' -annotate +0+8 'Installing Rowboat' \
|
||||
-gravity center -font "$FONT" -pointsize 14 -fill '#9a9a9a' -annotate +0+78 "$label" \
|
||||
-gravity center -font "$FONT" -pointsize 14 -fill '#6f6f6f' -annotate +0+100 "$dots" \
|
||||
"$OUT_DIR/frame-$(printf '%03d' "$i").png"
|
||||
done
|
||||
|
||||
magick -delay 50 -loop 0 "$OUT_DIR"/frame-*.png -layers Optimize "$(dirname "$0")/install-loading.gif"
|
||||
echo "frames: $FRAMES"
|
||||
ls -la "$(dirname "$0")/install-loading.gif"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
|
|
@ -1,134 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
// mic-monitor: prints a JSON line whenever the set of processes using the
|
||||
// microphone changes.
|
||||
//
|
||||
// This is the ambient meeting-detection signal (Granola-style): when another
|
||||
// app (Zoom, Meet in a browser, Slack huddle, FaceTime…) opens the
|
||||
// microphone, we report it. No audio is captured, so this requires no
|
||||
// microphone permission (TCC) — it's device state, not content.
|
||||
//
|
||||
// Two signals, best available wins:
|
||||
// - macOS 14.4+: per-process audio objects (kAudioHardwarePropertyProcessObjectList
|
||||
// + kAudioProcessPropertyIsRunningInput) give the processes that own the
|
||||
// mic. Each owner is resolved to its bundle ID (CoreAudio) and executable
|
||||
// path (libproc) HERE, natively — the consumer must not need to shell out
|
||||
// (child-process spawns from Electron's main process fail with EBADF
|
||||
// while capture is active).
|
||||
// - Fallback: kAudioDevicePropertyDeviceIsRunningSomewhere on the default
|
||||
// input device — mic in use by *someone*, no attribution.
|
||||
//
|
||||
// Protocol: one JSON object per line on stdout, emitted on every state
|
||||
// change (and once at startup):
|
||||
// {"micInUse":true,"owners":[{"pid":123,"bundleId":"com.google.Chrome.helper","path":"/Applications/..."}]}
|
||||
// ("owners" is empty when attribution is unavailable.)
|
||||
// The process exits when stdin closes, so it can never outlive the app.
|
||||
//
|
||||
// Compiled by apps/main/bundle.mjs (best-effort, macOS only) to
|
||||
// .package/dist/mic-monitor.
|
||||
|
||||
import Foundation
|
||||
import CoreAudio
|
||||
import Darwin
|
||||
|
||||
func defaultInputDeviceID() -> AudioDeviceID? {
|
||||
var deviceID = AudioDeviceID(0)
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &deviceID)
|
||||
guard status == noErr, deviceID != kAudioObjectUnknown else { return nil }
|
||||
return deviceID
|
||||
}
|
||||
|
||||
func isRunningSomewhere(_ device: AudioDeviceID) -> Bool {
|
||||
var running = UInt32(0)
|
||||
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
let status = AudioObjectGetPropertyData(device, &addr, 0, nil, &size, &running)
|
||||
return status == noErr && running != 0
|
||||
}
|
||||
|
||||
func audioProcessObjectIDs() -> [AudioObjectID] {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyProcessObjectList,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size = UInt32(0)
|
||||
let sysID = AudioObjectID(kAudioObjectSystemObject)
|
||||
guard AudioObjectGetPropertyDataSize(sysID, &addr, 0, nil, &size) == noErr, size > 0 else {
|
||||
return []
|
||||
}
|
||||
var ids = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout<AudioObjectID>.size)
|
||||
guard AudioObjectGetPropertyData(sysID, &addr, 0, nil, &size, &ids) == noErr else { return [] }
|
||||
return ids
|
||||
}
|
||||
|
||||
func processIsRunningInput(_ object: AudioObjectID) -> Bool {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioProcessPropertyIsRunningInput,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var value = UInt32(0)
|
||||
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||
let status = AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value)
|
||||
return status == noErr && value != 0
|
||||
}
|
||||
|
||||
func processPID(_ object: AudioObjectID) -> Int32? {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioProcessPropertyPID,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var pid = pid_t(-1)
|
||||
var size = UInt32(MemoryLayout<pid_t>.size)
|
||||
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &pid) == noErr, pid >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return Int32(pid)
|
||||
}
|
||||
|
||||
func processBundleID(_ object: AudioObjectID) -> String? {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioProcessPropertyBundleID,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var value: Unmanaged<CFString>? = nil
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr,
|
||||
let cf = value else { return nil }
|
||||
let str = cf.takeRetainedValue() as String
|
||||
return str.isEmpty ? nil : str
|
||||
}
|
||||
|
||||
func processPath(_ pid: Int32) -> String? {
|
||||
var buffer = [CChar](repeating: 0, count: 4096)
|
||||
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
struct MicOwner: Equatable {
|
||||
let pid: Int32
|
||||
let bundleId: String
|
||||
let path: String
|
||||
}
|
||||
|
||||
/// Processes currently capturing from any input device (macOS 14.4+;
|
||||
/// returns [] where unsupported and the device-level fallback takes over).
|
||||
func micOwners() -> [MicOwner] {
|
||||
var owners: [MicOwner] = []
|
||||
for object in audioProcessObjectIDs() where processIsRunningInput(object) {
|
||||
guard let pid = processPID(object) else { continue }
|
||||
owners.append(MicOwner(
|
||||
pid: pid,
|
||||
bundleId: processBundleID(object) ?? "",
|
||||
path: processPath(pid) ?? ""))
|
||||
}
|
||||
return owners.sorted { $0.pid < $1.pid }
|
||||
}
|
||||
|
||||
func jsonEscape(_ s: String) -> String {
|
||||
var out = ""
|
||||
for ch in s.unicodeScalars {
|
||||
switch ch {
|
||||
case "\"": out += "\\\""
|
||||
case "\\": out += "\\\\"
|
||||
case "\n": out += "\\n"
|
||||
case "\r": out += "\\r"
|
||||
case "\t": out += "\\t"
|
||||
default:
|
||||
if ch.value < 0x20 {
|
||||
out += String(format: "\\u%04x", ch.value)
|
||||
} else {
|
||||
out.unicodeScalars.append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Exit when the parent closes our stdin (app quit/crash) — never orphan.
|
||||
Thread {
|
||||
while readLine(strippingNewline: false) != nil {}
|
||||
exit(0)
|
||||
}.start()
|
||||
|
||||
setbuf(stdout, nil)
|
||||
|
||||
var lastInUse: Bool? = nil
|
||||
var lastOwners: [MicOwner] = []
|
||||
while true {
|
||||
let owners = micOwners()
|
||||
// Re-resolve the default device every poll: the user can switch input
|
||||
// devices (AirPods in/out) mid-session.
|
||||
let deviceInUse = defaultInputDeviceID().map(isRunningSomewhere) ?? false
|
||||
let inUse = deviceInUse || !owners.isEmpty
|
||||
if inUse != lastInUse || owners != lastOwners {
|
||||
lastInUse = inUse
|
||||
lastOwners = owners
|
||||
let ownerJson = owners.map { o in
|
||||
"{\"pid\":\(o.pid),\"bundleId\":\"\(jsonEscape(o.bundleId))\",\"path\":\"\(jsonEscape(o.path))\"}"
|
||||
}.joined(separator: ",")
|
||||
print("{\"micInUse\":\(inUse),\"owners\":[\(ownerJson)]}")
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
}
|
||||
|
|
@ -13,25 +13,20 @@
|
|||
"make": "electron-forge make"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-chrome-extensions": "^4.9.0",
|
||||
"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",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
|
|||
import { URL } from 'url';
|
||||
|
||||
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
||||
export const DEFAULT_PORT = 8080;
|
||||
export const PORT_RANGE_SIZE = 10;
|
||||
const DEFAULT_PORT = 8080;
|
||||
|
||||
/** Escape HTML special characters to prevent XSS */
|
||||
function escapeHtml(str: string): string {
|
||||
|
|
@ -20,21 +19,29 @@ export interface AuthServerResult {
|
|||
port: number;
|
||||
}
|
||||
|
||||
interface CallbackHandlingOpts {
|
||||
callbackPath: string;
|
||||
/** Invoked when the provider redirects back with an `error` param. */
|
||||
onError?: (error: string) => void;
|
||||
/**
|
||||
* Gatekeeper run BEFORE the error/callback handling. Return a message to
|
||||
* reject the request with a polite close-this-tab error page — without
|
||||
* invoking onCallback/onError. Lets a caller drop stale callbacks (e.g. the
|
||||
* browser tab of a cancelled sign-in attempt carrying an old `state`)
|
||||
* without disturbing the live flow.
|
||||
* Create a local HTTP server to handle OAuth callback
|
||||
* Listens on http://localhost:8080/oauth/callback
|
||||
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
||||
*/
|
||||
validateCallback?: (url: URL) => string | null;
|
||||
export function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
function renderErrorPage(res: import('http').ServerResponse, message: string): void {
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
|
|
@ -48,47 +55,12 @@ function renderErrorPage(res: import('http').ServerResponse, message: string): v
|
|||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<p>Error: ${escapeHtml(error)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
function tryBindPort(
|
||||
port: number,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: CallbackHandlingOpts,
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === opts.callbackPath) {
|
||||
// Gatekeeper first: stale/foreign requests must not reach onError or
|
||||
// onCallback (a stale tab's redirect must never settle a live flow).
|
||||
const rejection = opts.validateCallback?.(url) ?? null;
|
||||
if (rejection) {
|
||||
renderErrorPage(res, rejection);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
// Surface the provider error (e.g. access_denied when the user
|
||||
// cancels consent) so the caller can settle its flow instead of
|
||||
// waiting for the timeout. Callers that don't opt in keep the old
|
||||
// behaviour: the error page renders and the flow times out.
|
||||
opts.onError?.(error);
|
||||
renderErrorPage(res, `Error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -124,10 +96,8 @@ function tryBindPort(
|
|||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
server.close();
|
||||
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
||||
// Signal caller to try next port
|
||||
reject(Object.assign(new Error(err.code), { code: err.code }));
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
|
|
@ -135,62 +105,3 @@ function tryBindPort(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local HTTP server to handle OAuth callback.
|
||||
*
|
||||
* Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
|
||||
* thrown if it cannot be bound. This is the right behaviour for any provider
|
||||
* whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those
|
||||
* callers must keep using the exact port they've handed to the provider.
|
||||
*
|
||||
* Opt into `{ fallback: true }` only when the caller is prepared to use the
|
||||
* port returned in `AuthServerResult` (i.e. the redirect URI is built from the
|
||||
* actual bound port, not hard-coded). With fallback enabled, scans `port`
|
||||
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
||||
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
||||
* Hyper-V/WSL2 reserve the port).
|
||||
*
|
||||
* `callbackPath` overrides the served path for providers whose registered
|
||||
* redirect URI differs (ChatGPT/Codex uses /auth/callback). `onError` is
|
||||
* invoked when the provider redirects back with an `error` param (e.g.
|
||||
* access_denied), letting the caller settle instead of waiting for timeout.
|
||||
* `validateCallback` runs before both — see CallbackHandlingOpts.
|
||||
*/
|
||||
export async function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: { fallback?: boolean } & Partial<CallbackHandlingOpts> = {},
|
||||
): Promise<AuthServerResult> {
|
||||
const fallback = opts.fallback === true;
|
||||
const handlingOpts: CallbackHandlingOpts = {
|
||||
callbackPath: opts.callbackPath ?? OAUTH_CALLBACK_PATH,
|
||||
onError: opts.onError,
|
||||
validateCallback: opts.validateCallback,
|
||||
};
|
||||
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
||||
|
||||
for (let p = port; p <= limit; p++) {
|
||||
try {
|
||||
return await tryBindPort(p, onCallback, handlingOpts);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
||||
console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`);
|
||||
continue;
|
||||
}
|
||||
if (!fallback) {
|
||||
const reason = code === 'EACCES' || code === 'EADDRINUSE'
|
||||
? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
|
||||
: (err instanceof Error ? err.message : String(err));
|
||||
throw new Error(reason);
|
||||
}
|
||||
throw new Error(
|
||||
`No available port found in range ${port}–${limit}. Free a port in that range and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — loop always returns or throws — but satisfies TypeScript
|
||||
throw new Error(`No available port found in range ${port}–${limit}.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { session, type BrowserWindow, type Session, type WebContents } from 'electron';
|
||||
import { ElectronChromeExtensions } from 'electron-chrome-extensions';
|
||||
import { WorkDir } from '@x/core/dist/config/config.js';
|
||||
import { browserViewManager } from './view.js';
|
||||
|
||||
/**
|
||||
* Chrome extension support for the embedded browser, via
|
||||
* electron-chrome-extensions (GPL-3.0 / Patron dual-licensed — see the
|
||||
* library's LICENSE.md before shipping this in a release build).
|
||||
*
|
||||
* Extensions are loaded unpacked from ~/.rowboat/extensions/<name>/ — either
|
||||
* a directory containing manifest.json directly, or (Chrome Web Store
|
||||
* unpacked layout) a directory whose single versioned subdirectory contains
|
||||
* it. There is no install UI; drop a folder there and restart the app.
|
||||
*
|
||||
* Known limits:
|
||||
* - The browser session's client-hint spoofing uses Electron's webRequest
|
||||
* API, which prevents extensions' chrome.webRequest listeners from firing
|
||||
* (Electron allows one consumer per session). Blockers that rely on
|
||||
* webRequest (uBlock MV2) won't block; content-script-based extensions
|
||||
* (Dark Reader, password managers) work.
|
||||
* - declarativeNetRequest is not implemented by Electron.
|
||||
*/
|
||||
|
||||
const EXTENSIONS_DIR = path.join(WorkDir, 'extensions');
|
||||
|
||||
let extensions: ElectronChromeExtensions | null = null;
|
||||
|
||||
/**
|
||||
* Called once at startup (before any browser use). Binds the extension
|
||||
* system to the browser session when the BrowserViewManager creates it, and
|
||||
* mirrors the manager's tab lifecycle into chrome.tabs.
|
||||
*/
|
||||
export function setupBrowserExtensions(): void {
|
||||
browserViewManager.on('session-created', (browserSession: Session) => {
|
||||
initExtensions(browserSession);
|
||||
});
|
||||
browserViewManager.on('tab-created', (wc: WebContents, win: BrowserWindow) => {
|
||||
try {
|
||||
extensions?.addTab(wc, win);
|
||||
} catch (error) {
|
||||
console.error('[Extensions] addTab failed:', error);
|
||||
}
|
||||
});
|
||||
browserViewManager.on('tab-selected', (wc: WebContents) => {
|
||||
try {
|
||||
extensions?.selectTab(wc);
|
||||
} catch (error) {
|
||||
console.error('[Extensions] selectTab failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Serves crx:// (extension action icons) to the app renderer, which hosts
|
||||
// the <browser-action-list> element on the default session; the element's
|
||||
// partition attribute routes state queries to the browser session.
|
||||
ElectronChromeExtensions.handleCRXProtocol(session.defaultSession);
|
||||
}
|
||||
|
||||
function initExtensions(browserSession: Session): void {
|
||||
if (extensions) return;
|
||||
|
||||
extensions = new ElectronChromeExtensions({
|
||||
license: 'GPL-3.0',
|
||||
session: browserSession,
|
||||
async createTab(details) {
|
||||
const result = await browserViewManager.newTab(details.url);
|
||||
if (!result.ok || !result.tabId) {
|
||||
throw new Error(result.error ?? 'Failed to create tab');
|
||||
}
|
||||
const wc = browserViewManager.getTabWebContents(result.tabId);
|
||||
const win = browserViewManager.getWindow();
|
||||
if (!wc || !win) throw new Error('Browser window is not available');
|
||||
return [wc, win];
|
||||
},
|
||||
selectTab(wc) {
|
||||
const tabId = browserViewManager.getTabIdForWebContents(wc);
|
||||
if (tabId) browserViewManager.switchTab(tabId);
|
||||
},
|
||||
removeTab(wc) {
|
||||
const tabId = browserViewManager.getTabIdForWebContents(wc);
|
||||
if (tabId) browserViewManager.closeTab(tabId);
|
||||
},
|
||||
});
|
||||
|
||||
void loadUnpackedExtensions(browserSession);
|
||||
}
|
||||
|
||||
async function resolveExtensionRoot(dir: string): Promise<string | null> {
|
||||
const hasManifest = async (candidate: string) =>
|
||||
fs.access(path.join(candidate, 'manifest.json')).then(() => true, () => false);
|
||||
|
||||
if (await hasManifest(dir)) return dir;
|
||||
|
||||
// Chrome Web Store unpacked layout: <id>/<version>/manifest.json. Pick the
|
||||
// lexically-latest version directory.
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
const versionDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const name of versionDirs) {
|
||||
const candidate = path.join(dir, name);
|
||||
if (await hasManifest(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadUnpackedExtensions(browserSession: Session): Promise<void> {
|
||||
await fs.mkdir(EXTENSIONS_DIR, { recursive: true });
|
||||
const entries = await fs.readdir(EXTENSIONS_DIR, { withFileTypes: true }).catch(() => []);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const root = await resolveExtensionRoot(path.join(EXTENSIONS_DIR, entry.name));
|
||||
if (!root) {
|
||||
console.warn(`[Extensions] No manifest.json under ${path.join(EXTENSIONS_DIR, entry.name)} — skipped`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const extension = await browserSession.extensions.loadExtension(root);
|
||||
console.log(`[Extensions] Loaded ${extension.name}@${extension.version} (${root})`);
|
||||
} catch (error) {
|
||||
console.error(`[Extensions] Failed to load ${root}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import { browserViewManager, type BrowserState, type DisplayMediaRequest, type HttpAuthRequest } from './view.js';
|
||||
import { browserViewManager, type BrowserState } from './view.js';
|
||||
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -20,8 +20,6 @@ type BrowserHandlers = {
|
|||
'browser:forward': InvokeHandler<'browser:forward'>;
|
||||
'browser:reload': InvokeHandler<'browser:reload'>;
|
||||
'browser:getState': InvokeHandler<'browser:getState'>;
|
||||
'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>;
|
||||
'browser:displayMediaResponse': InvokeHandler<'browser:displayMediaResponse'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -64,12 +62,6 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
'browser:getState': async () => {
|
||||
return browserViewManager.getState();
|
||||
},
|
||||
'browser:httpAuthResponse': async (_event, args) => {
|
||||
return browserViewManager.respondToHttpAuth(args);
|
||||
},
|
||||
'browser:displayMediaResponse': async (_event, args) => {
|
||||
return browserViewManager.respondToDisplayMedia(args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -78,34 +70,12 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
* window is created so the manager has a window to attach to.
|
||||
*/
|
||||
export function setupBrowserEventForwarding(): void {
|
||||
// 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 });
|
||||
});
|
||||
|
||||
browserViewManager.on('display-media-request', (request: DisplayMediaRequest) => {
|
||||
broadcast('browser:displayMediaRequest', request);
|
||||
});
|
||||
|
||||
browserViewManager.on('display-media-resolved', (requestId: string) => {
|
||||
broadcast('browser:displayMediaResolved', { requestId });
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('browser:didUpdateState', state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserWindow, WebContentsView, desktopCapturer, session, shell, type Session, type WebContents } from 'electron';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
|
||||
import type {
|
||||
BrowserPageElement,
|
||||
BrowserPageSnapshot,
|
||||
BrowserState,
|
||||
BrowserTabState,
|
||||
DisplayMediaRequest,
|
||||
HttpAuthRequest,
|
||||
} from '@x/shared/dist/browser-control.js';
|
||||
import { normalizeNavigationTarget } from './navigation.js';
|
||||
import {
|
||||
|
|
@ -22,7 +20,7 @@ import {
|
|||
type RawBrowserPageSnapshot,
|
||||
} from './page-scripts.js';
|
||||
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState, DisplayMediaRequest, HttpAuthRequest };
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
|
||||
|
||||
/**
|
||||
* Embedded browser pane implementation.
|
||||
|
|
@ -38,35 +36,13 @@ export type { BrowserPageSnapshot, BrowserState, BrowserTabState, DisplayMediaRe
|
|||
|
||||
export const BROWSER_PARTITION = 'persist:rowboat-browser';
|
||||
|
||||
// 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();
|
||||
// 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';
|
||||
|
||||
const HOME_URL = 'https://www.google.com';
|
||||
const NAVIGATION_TIMEOUT_MS = 10000;
|
||||
const HTTP_AUTH_TIMEOUT_MS = 120000;
|
||||
const DISPLAY_MEDIA_TIMEOUT_MS = 120000;
|
||||
const DISPLAY_MEDIA_THUMBNAIL_SIZE = { width: 320, height: 180 };
|
||||
const POST_ACTION_IDLE_MS = 400;
|
||||
const POST_ACTION_MAX_ELEMENTS = 25;
|
||||
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
|
||||
|
|
@ -92,21 +68,6 @@ 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;
|
||||
};
|
||||
|
||||
type PendingDisplayMedia = {
|
||||
callback: (streams: Electron.Streams) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
// The picker answers with a source id; the native callback needs the full
|
||||
// DesktopCapturerSource, so keep the gathered sources until resolution.
|
||||
sources: Map<string, Electron.DesktopCapturerSource>;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
|
|
@ -148,13 +109,6 @@ 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>();
|
||||
private pendingDisplayMedia = new Map<string, PendingDisplayMedia>();
|
||||
// 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 {
|
||||
|
|
@ -183,7 +137,6 @@ 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;
|
||||
|
|
@ -197,17 +150,6 @@ export class BrowserViewManager extends EventEmitter {
|
|||
this.attachedTabId = null;
|
||||
this.visible = false;
|
||||
this.snapshotCache.clear();
|
||||
for (const requestId of [...this.pendingHttpAuth.keys()]) {
|
||||
this.finishHttpAuth(requestId);
|
||||
}
|
||||
for (const requestId of [...this.pendingDisplayMedia.keys()]) {
|
||||
this.finishDisplayMedia(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);
|
||||
|
|
@ -229,138 +171,10 @@ 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 });
|
||||
});
|
||||
|
||||
// getDisplayMedia() from pages (e.g. Google Meet "Present now"). When the
|
||||
// browser pane is on screen, forward a source picker to the renderer and
|
||||
// resolve with the user's choice. Registered here (not in main.ts's
|
||||
// configureSessionPermissions) so the picker plumbing lives with the rest
|
||||
// of the pane's request/response wiring; the app's own session keeps its
|
||||
// auto-approve loopback handler for meeting transcription.
|
||||
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
this.handleDisplayMediaRequest(callback).catch(() => callback({}));
|
||||
});
|
||||
|
||||
this.browserSession = browserSession;
|
||||
// Synchronous on purpose: the extension system (browser/extensions.ts)
|
||||
// must bind to the session — registering its tab-API preload — before the
|
||||
// first tab's WebContentsView is constructed right after this returns.
|
||||
this.emit('session-created', browserSession);
|
||||
return browserSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page's getDisplayMedia() request. With the pane visible, gather
|
||||
* shareable sources and ask the renderer to show a picker (denied after a
|
||||
* timeout if unanswered). With the pane hidden, deny outright: nobody can
|
||||
* answer a picker, and auto-granting would hand a live screen capture to an
|
||||
* arbitrary page without consent. `visible` is also false whenever the
|
||||
* renderer hides the view behind a blocking overlay — including this
|
||||
* picker's own dialog — so a page re-requesting mid-picker lands here and
|
||||
* must be denied, not silently granted.
|
||||
*/
|
||||
private async handleDisplayMediaRequest(callback: (streams: Electron.Streams) => void): Promise<void> {
|
||||
if (!this.visible || !this.window) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: DISPLAY_MEDIA_THUMBNAIL_SIZE,
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
if (sources.length === 0) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
this.finishDisplayMedia(requestId);
|
||||
}, DISPLAY_MEDIA_TIMEOUT_MS);
|
||||
this.pendingDisplayMedia.set(requestId, {
|
||||
callback,
|
||||
timer,
|
||||
sources: new Map(sources.map((source) => [source.id, source])),
|
||||
});
|
||||
|
||||
const request: DisplayMediaRequest = {
|
||||
requestId,
|
||||
sources: sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
kind: source.id.startsWith('screen:') ? 'screen' as const : 'window' as const,
|
||||
thumbnailDataUrl: source.thumbnail.isEmpty() ? '' : source.thumbnail.toDataURL(),
|
||||
// appIcon is typed non-null but is absent for screen sources.
|
||||
appIconDataUrl: source.appIcon && !source.appIcon.isEmpty() ? source.appIcon.toDataURL() : null,
|
||||
})),
|
||||
};
|
||||
this.emit('display-media-request', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending display-media request. `sourceId === undefined` (or an
|
||||
* id no longer in the gathered set) denies it. Always notifies the renderer
|
||||
* so a picker it may still be showing (e.g. after a timeout) is pruned.
|
||||
*/
|
||||
private finishDisplayMedia(requestId: string, sourceId?: string, audio?: boolean): boolean {
|
||||
const pending = this.pendingDisplayMedia.get(requestId);
|
||||
if (!pending) return false;
|
||||
this.pendingDisplayMedia.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
const source = sourceId != null ? pending.sources.get(sourceId) : undefined;
|
||||
try {
|
||||
if (!source) {
|
||||
pending.callback({});
|
||||
} else if (audio) {
|
||||
pending.callback({ video: source, audio: 'loopback' });
|
||||
} else {
|
||||
pending.callback({ video: source });
|
||||
}
|
||||
} catch {
|
||||
// The requesting webContents may already be destroyed.
|
||||
}
|
||||
this.emit('display-media-resolved', requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
respondToDisplayMedia(input: {
|
||||
requestId: string;
|
||||
sourceId?: string;
|
||||
audio?: boolean;
|
||||
}): { ok: boolean } {
|
||||
return { ok: this.finishDisplayMedia(input.requestId, input.sourceId, input.audio) };
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
this.emit('state-updated', this.snapshotState());
|
||||
}
|
||||
|
|
@ -382,34 +196,17 @@ 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 {
|
||||
private createView(): WebContentsView {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
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: this.browserWebPreferences(),
|
||||
},
|
||||
});
|
||||
|
||||
view.webContents.setUserAgent(SPOOF_UA);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
|
@ -469,143 +266,14 @@ export class BrowserViewManager extends EventEmitter {
|
|||
});
|
||||
wc.on('page-title-updated', this.emitState.bind(this));
|
||||
|
||||
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;
|
||||
|
||||
wc.setWindowOpenHandler(({ url }) => {
|
||||
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 ?? '');
|
||||
void shell.openExternal(url);
|
||||
}
|
||||
} 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) };
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
private snapshotTabState(tab: BrowserTab): BrowserTabState {
|
||||
|
|
@ -673,10 +341,6 @@ export class BrowserViewManager extends EventEmitter {
|
|||
this.invalidateSnapshot(tabId);
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
// Register with the extension system (chrome.tabs) before the initial
|
||||
// load below so extensions observe the tab's first navigation.
|
||||
this.emit('tab-created', tab.view.webContents, this.window);
|
||||
this.emit('tab-selected', tab.view.webContents);
|
||||
|
||||
const targetUrl =
|
||||
initialUrl === 'about:blank'
|
||||
|
|
@ -700,10 +364,6 @@ 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();
|
||||
|
|
@ -850,13 +510,11 @@ export class BrowserViewManager extends EventEmitter {
|
|||
}
|
||||
|
||||
switchTab(tabId: string): { ok: boolean } {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return { ok: false };
|
||||
if (!this.tabs.has(tabId)) return { ok: false };
|
||||
if (this.activeTabId === tabId) return { ok: true };
|
||||
this.activeTabId = tabId;
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
this.emit('tab-selected', tab.view.webContents);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
|
@ -1167,24 +825,6 @@ export class BrowserViewManager extends EventEmitter {
|
|||
return this.snapshotState();
|
||||
}
|
||||
|
||||
// Accessors for the extension system's chrome.tabs bridge
|
||||
// (browser/extensions.ts), which speaks in WebContents while the manager
|
||||
// speaks in tab ids.
|
||||
getWindow(): BrowserWindow | null {
|
||||
return this.window;
|
||||
}
|
||||
|
||||
getTabWebContents(tabId: string): WebContents | null {
|
||||
return this.getTab(tabId)?.view.webContents ?? null;
|
||||
}
|
||||
|
||||
getTabIdForWebContents(wc: WebContents): string | null {
|
||||
for (const tab of this.tabs.values()) {
|
||||
if (tab.view.webContents === wc) return tab.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private snapshotState(): BrowserState {
|
||||
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import {
|
||||
CHATGPT_AUTHORIZE_URL,
|
||||
CHATGPT_CALLBACK_PATH,
|
||||
CHATGPT_CALLBACK_PORT,
|
||||
CHATGPT_CLIENT_ID,
|
||||
CHATGPT_EXTRA_AUTHORIZE_PARAMS,
|
||||
CHATGPT_REDIRECT_URI,
|
||||
CHATGPT_SCOPES,
|
||||
} from '@x/core/dist/auth/chatgpt-constants.js';
|
||||
|
||||
// Interactive "Sign in with ChatGPT" flow (OAuth 2.0 + PKCE, Codex CLI client
|
||||
// — see chatgpt-constants.ts). Orchestration only: PKCE/state generation and
|
||||
// all token-endpoint traffic + storage live in core; this module owns the
|
||||
// system browser, the loopback callback server on 127.0.0.1:1455, and flow
|
||||
// lifecycle. The port is FIXED — the redirect URI is pre-registered at OpenAI
|
||||
// for the Codex client id, so there is no scan-to-next-port fallback.
|
||||
|
||||
export type ChatGPTSignInResult = {
|
||||
signedIn: boolean;
|
||||
email?: string;
|
||||
accountId?: string;
|
||||
/** True when the attempt was cancelled (Cancel button or superseded). */
|
||||
cancelled?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Generous, mirrors the Google flow's abandoned-flow cleanup ceiling. */
|
||||
const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type ActiveAttempt = {
|
||||
promise: Promise<ChatGPTSignInResult>;
|
||||
/**
|
||||
* Settle the attempt with a cancelled outcome. Resolves once the loopback
|
||||
* server has fully closed (listener + keep-alive connections), so a
|
||||
* follow-up attempt can rebind 1455 immediately.
|
||||
*/
|
||||
cancel: (reason: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let activeAttempt: ActiveAttempt | null = null;
|
||||
|
||||
/**
|
||||
* Start a sign-in attempt. If one is already pending it is stale by
|
||||
* definition — the user is clicking Sign In again precisely because no
|
||||
* browser flow is visibly in progress (e.g. they closed the tab and hit
|
||||
* Cancel) — so cancel it and start FRESH (new PKCE verifier/state, new
|
||||
* loopback server, new browser window). Awaiting the cancel preserves the
|
||||
* one-server-at-a-time invariant: 1455 is fully released before rebinding.
|
||||
*/
|
||||
export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
|
||||
if (activeAttempt) {
|
||||
const stale = activeAttempt;
|
||||
activeAttempt = null;
|
||||
console.log('[ChatGPTAuth] Cancelling stale sign-in attempt before starting a new one');
|
||||
await stale.cancel('Superseded by a new sign-in attempt.');
|
||||
}
|
||||
|
||||
const attempt = startAttempt();
|
||||
activeAttempt = attempt;
|
||||
void attempt.promise.finally(() => {
|
||||
if (activeAttempt === attempt) activeAttempt = null;
|
||||
});
|
||||
return attempt.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the pending attempt (renderer Cancel button): stops the loopback
|
||||
* server, clears pending state, settles the in-flight signIn promise with a
|
||||
* cancelled outcome. No-op when nothing is pending. Never touches stored
|
||||
* tokens — after cancel, chatgpt:getStatus reports signed-out (unless an
|
||||
* earlier sign-in already completed).
|
||||
*/
|
||||
export async function cancelChatGPTSignIn(): Promise<void> {
|
||||
const attempt = activeAttempt;
|
||||
if (!attempt) return;
|
||||
activeAttempt = null;
|
||||
await attempt.cancel('Sign-in cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* One sign-in attempt. The returned promise always RESOLVES (never rejects),
|
||||
* and every exit path — success, denial, timeout, port busy, exchange
|
||||
* failure, cancellation — tears down the loopback server and the timeout
|
||||
* exactly once via the settle-once `finish`.
|
||||
*/
|
||||
function startAttempt(): ActiveAttempt {
|
||||
let settle!: (result: ChatGPTSignInResult) => void;
|
||||
const promise = new Promise<ChatGPTSignInResult>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let server: Server | null = null;
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
let serverClosed: Promise<void> | null = null;
|
||||
|
||||
// Close the listening socket AND any keep-alive connections (the browser
|
||||
// holds one open after the callback response) so 1455 frees immediately.
|
||||
const closeServer = (): Promise<void> => {
|
||||
if (serverClosed) return serverClosed;
|
||||
const s = server;
|
||||
server = null;
|
||||
serverClosed = !s
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
s.closeAllConnections();
|
||||
});
|
||||
return serverClosed;
|
||||
};
|
||||
|
||||
const finish = (result: ChatGPTSignInResult): Promise<void> => {
|
||||
if (settled) return serverClosed ?? Promise.resolve();
|
||||
settled = true;
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
const closed = closeServer();
|
||||
if (!result.signedIn) {
|
||||
console.log(`[ChatGPTAuth] Sign-in did not complete: ${result.error ?? 'unknown'}`);
|
||||
}
|
||||
settle(result);
|
||||
return closed;
|
||||
};
|
||||
|
||||
const cancel = (reason: string): Promise<void> =>
|
||||
finish({ signedIn: false, cancelled: true, error: reason });
|
||||
|
||||
void run();
|
||||
return { promise, cancel };
|
||||
|
||||
async function run(): Promise<void> {
|
||||
console.log('[ChatGPTAuth] Starting sign-in flow...');
|
||||
try {
|
||||
const { verifier, challenge } = await oauthClient.generatePKCE();
|
||||
const state = oauthClient.generateState();
|
||||
if (settled) return; // cancelled while generating PKCE — nothing bound yet
|
||||
|
||||
// Guard against duplicate callbacks (browser may send multiple requests).
|
||||
let callbackHandled = false;
|
||||
const onCallback = async (callbackUrl: URL) => {
|
||||
if (settled || callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
try {
|
||||
// State already verified by validateCallback below.
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
if (!code) {
|
||||
void finish({ signedIn: false, error: 'Sign-in failed: callback is missing the authorization code.' });
|
||||
return;
|
||||
}
|
||||
// Exchange + persistence live in core (never log token values).
|
||||
await exchangeChatGPTCode(code, verifier);
|
||||
const status = await getChatGPTStatus();
|
||||
console.log('[ChatGPTAuth] Sign-in complete');
|
||||
void finish({ ...status });
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Token exchange failed:', error);
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Token exchange failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Bind the loopback server FIRST so a busy port fails fast, before any
|
||||
// browser tab opens. Fixed port — createAuthServer's no-fallback error
|
||||
// message tells the user to free the port.
|
||||
let boundServer: Server;
|
||||
try {
|
||||
({ server: boundServer } = await createAuthServer(CHATGPT_CALLBACK_PORT, onCallback, {
|
||||
callbackPath: CHATGPT_CALLBACK_PATH,
|
||||
onError: (error) => {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error === 'access_denied'
|
||||
? 'Sign-in was cancelled in the browser.'
|
||||
: `Sign-in failed: ${error}`,
|
||||
});
|
||||
},
|
||||
// Stale callbacks — a tab left over from an earlier, cancelled
|
||||
// attempt carries that attempt's `state` — get a polite
|
||||
// close-this-tab page and never reach onError/onCallback, so they
|
||||
// can neither complete sign-in nor settle the live attempt.
|
||||
validateCallback: (url) => {
|
||||
if (settled) {
|
||||
return 'This sign-in attempt is no longer active. Close this tab and try again from Rowboat.';
|
||||
}
|
||||
if (url.searchParams.get('state') !== state) {
|
||||
return 'This sign-in link has expired. Close this tab and try again from Rowboat.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to start the sign-in callback server',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
// Cancelled while the bind was in flight — release the port we just
|
||||
// grabbed (finish() already ran with no server to close).
|
||||
boundServer.closeAllConnections();
|
||||
boundServer.close();
|
||||
return;
|
||||
}
|
||||
server = boundServer;
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
void finish({ signedIn: false, error: 'Sign-in timed out. Please try again.' });
|
||||
}, SIGN_IN_TIMEOUT_MS);
|
||||
|
||||
const authUrl = new URL(CHATGPT_AUTHORIZE_URL);
|
||||
authUrl.search = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
redirect_uri: CHATGPT_REDIRECT_URI,
|
||||
scope: CHATGPT_SCOPES.join(' '),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
...CHATGPT_EXTRA_AUTHORIZE_PARAMS,
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
// System browser: shares the user's existing ChatGPT session cookies.
|
||||
await shell.openExternal(authUrl.toString());
|
||||
} catch (error) {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? `Failed to open browser: ${error.message}` : 'Failed to open browser',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Sign-in flow error:', error);
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Sign-in failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { shell, BrowserWindow } from 'electron';
|
|||
import { createAuthServer } from './auth-server.js';
|
||||
import * as composioClient from '@x/core/dist/composio/client.js';
|
||||
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/runtime/assembly/copilot/instructions.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js';
|
||||
import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js';
|
||||
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
|
|
@ -315,50 +315,3 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ 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 {
|
||||
|
|
@ -160,41 +158,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
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
|
|
@ -1,62 +1,44 @@
|
|||
import { app, BrowserWindow, desktopCapturer, dialog, protocol, net, shell, session, safeStorage, type Session } from "electron";
|
||||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher, startSessionsWatcher, startTurnEventsWatcher, markSessionsIndexReady,
|
||||
startCodeRunFeedWatcher,
|
||||
startChannelsWatcher,
|
||||
startCodeSessionStatusWatcher,
|
||||
startRunsWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
startBackgroundTaskAgentWatcher,
|
||||
startWorkspaceWatcher,
|
||||
stopRunsWatcher,
|
||||
stopServicesWatcher,
|
||||
stopWorkspaceWatcher
|
||||
} from "./ipc.js";
|
||||
import { disposeAllTerminals } from "./terminal.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { initUpdater } from "./updater.js";
|
||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
||||
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
|
||||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
||||
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
||||
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
|
||||
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 { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/assembly/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 { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js";
|
||||
import { init as initLiveNoteEventProcessor } from "@x/core/dist/knowledge/live-note/events.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.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 { execFileSync } from "node:child_process";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
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/runtime/sessions/index.js";
|
||||
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { setupBrowserExtensions } from "./browser/extensions.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
||||
import {
|
||||
|
|
@ -65,45 +47,18 @@ import {
|
|||
extractDeepLinkFromArgv,
|
||||
setMainWindowForDeepLinks,
|
||||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
import { startModelsDevRefresh } from "@x/core/dist/models/models-dev.js";
|
||||
import { loadAppSettings, saveAppSettings } from "@x/core/dist/config/app_settings.js";
|
||||
import { init as initMeetingDetection } from "@x/core/dist/meetings/detector.js";
|
||||
import { createAppTray, hasTray, isRecordingActive, markPendingToggleMeetingNotes } from "./tray.js";
|
||||
import { initMeetingPopup, showMeetingPopup } from "./meeting-popup.js";
|
||||
|
||||
// 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 execAsync = promisify(exec);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// fs.watch failures (EMFILE fd exhaustion, ENOSPC watch limits) surface as
|
||||
// uncaught exceptions from Node's watcher internals, bypassing chokidar's
|
||||
// 'error' handlers. Watching is a degradable feature — log and keep running.
|
||||
// Everything else keeps Electron's default behavior (native error dialog),
|
||||
// which we replicate since registering any listener suppresses it.
|
||||
process.on('uncaughtException', (err) => {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
if ((code === 'EMFILE' || code === 'ENOSPC') && (err?.stack ?? '').includes('FSWatcher')) {
|
||||
console.error('[Main] file watcher error (non-fatal):', err);
|
||||
return;
|
||||
}
|
||||
console.error('[Main] uncaught exception:', err);
|
||||
dialog.showErrorBox(
|
||||
'A JavaScript error occurred in the main process',
|
||||
err?.stack ?? String(err),
|
||||
);
|
||||
});
|
||||
|
||||
// run this as early in the main process as possible
|
||||
if (started) app.quit();
|
||||
|
||||
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
|
||||
// back into the existing process via the 'second-instance' event.
|
||||
if (app.isPackaged && !app.requestSingleInstanceLock()) {
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
console.error('[Main] Another Rowboat instance is already running; exiting this process.');
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
|
|
@ -228,34 +183,18 @@ protocol.registerSchemesAsPrivileged([
|
|||
|
||||
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
|
||||
|
||||
// Granted to the embedded browser partition on top of the base set.
|
||||
// `notifications` lets sites (WhatsApp Web, Gmail, Slack, ...) show native OS
|
||||
// notifications via the HTML5 Notification API — Electron renders these
|
||||
// through the system notification center once the permission resolves to
|
||||
// granted. Background Web Push is still unavailable (Electron has no FCM),
|
||||
// so notifications only fire while the site is loaded in a tab. The app's
|
||||
// own renderer keeps the base set; it notifies through the main-process
|
||||
// notification service instead.
|
||||
const BROWSER_EXTRA_PERMISSIONS = ["notifications"] as const;
|
||||
|
||||
function configureSessionPermissions(targetSession: Session, extraPermissions: readonly string[] = []): void {
|
||||
const allowed = new Set([...ALLOWED_SESSION_PERMISSIONS, ...extraPermissions]);
|
||||
|
||||
function configureSessionPermissions(targetSession: Session): void {
|
||||
targetSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
return allowed.has(permission);
|
||||
return ALLOWED_SESSION_PERMISSIONS.has(permission);
|
||||
});
|
||||
|
||||
targetSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
callback(allowed.has(permission));
|
||||
callback(ALLOWED_SESSION_PERMISSIONS.has(permission));
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-approve display media requests and route system audio as loopback.
|
||||
// Electron requires a video source in the callback even if we only want audio.
|
||||
// We pass the first available screen source; the renderer discards the video track.
|
||||
// App session only — the embedded browser partition registers its own handler
|
||||
// (a user-facing source picker) in BrowserViewManager.
|
||||
function configureAppDisplayMediaHandler(targetSession: Session): void {
|
||||
targetSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
const sources = await desktopCapturer.getSources({ types: ['screen'] });
|
||||
if (sources.length === 0) {
|
||||
|
|
@ -266,81 +205,7 @@ function configureAppDisplayMediaHandler(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resident-app plumbing (Granola-style): the main window may exist hidden
|
||||
// (launched at login) or not at all (closed on macOS) while the app keeps
|
||||
// running from the tray. showApp() is the single "bring the app up" path
|
||||
// used by the tray, the Dock, and pending tray commands.
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function showApp(): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
if (!mainWindow.isVisible()) mainWindow.maximize();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
// The user is usually in another app (a meeting!) when this runs — a plain
|
||||
// focus() won't take the foreground from it.
|
||||
app.focus({ steal: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this process launched by the OS at login (rather than by the user)?
|
||||
* Used to start with the window hidden so login launches are invisible.
|
||||
*
|
||||
* - Windows: our login item registers with an explicit --hidden arg.
|
||||
* - macOS: wasOpenedAtLogin when available. On macOS 13+ (SMAppService)
|
||||
* Electron doesn't reliably populate it (electron#37244), so fall back to
|
||||
* a heuristic: a packaged launch while registered as a login item within
|
||||
* two minutes of boot is treated as a login launch.
|
||||
*/
|
||||
function wasLaunchedAtLogin(): boolean {
|
||||
if (process.argv.includes("--hidden")) return true;
|
||||
if (process.platform !== "darwin" || !app.isPackaged) return false;
|
||||
try {
|
||||
const settings = app.getLoginItemSettings();
|
||||
if (settings.wasOpenedAtLogin) return true;
|
||||
return settings.openAtLogin && os.uptime() < 120;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(options: { startHidden?: boolean } = {}) {
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
|
|
@ -350,7 +215,6 @@ function createWindow(options: { startHidden?: boolean } = {}) {
|
|||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
|
|
@ -364,21 +228,13 @@ function createWindow(options: { startHidden?: boolean } = {}) {
|
|||
});
|
||||
|
||||
configureSessionPermissions(session.defaultSession);
|
||||
configureAppDisplayMediaHandler(session.defaultSession);
|
||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION), BROWSER_EXTRA_PERMISSIONS);
|
||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||
|
||||
mainWindow = win;
|
||||
setMainWindowForDeepLinks(win);
|
||||
win.on("closed", () => {
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
setMainWindowForDeepLinks(null);
|
||||
});
|
||||
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||
|
||||
// Show window when content is ready to prevent blank screen.
|
||||
// Launched-at-login starts stay hidden: the app is reachable from the
|
||||
// tray/Dock, and showApp() maximizes on first reveal.
|
||||
// Show window when content is ready to prevent blank screen
|
||||
win.once("ready-to-show", () => {
|
||||
if (options.startHidden) return;
|
||||
win.maximize();
|
||||
win.show();
|
||||
});
|
||||
|
|
@ -390,43 +246,20 @@ function createWindow(options: { startHidden?: boolean } = {}) {
|
|||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// 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 => {
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
const isInternal =
|
||||
url.startsWith("app://") || url.startsWith("http://localhost:5173");
|
||||
if (isInternal) return false;
|
||||
if (!isInternal) {
|
||||
event.preventDefault();
|
||||
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 {
|
||||
|
|
@ -434,43 +267,39 @@ function createWindow(options: { startHidden?: boolean } = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// serves workspace files via app://workspace/<rel-path> for media previews.
|
||||
registerAppProtocol();
|
||||
|
||||
// Initialize auto-updater (no-ops in dev). Update state is pushed to the
|
||||
// renderer (updater:status), which owns the restart prompt — see updater.ts.
|
||||
initUpdater();
|
||||
// Initialize auto-updater (only in production)
|
||||
if (app.isPackaged) {
|
||||
updateElectronApp({
|
||||
updateSource: {
|
||||
type: UpdateSourceType.ElectronPublicUpdateService,
|
||||
repo: "rowboatlabs/rowboat",
|
||||
},
|
||||
notifyUser: true, // Shows native dialog when update is available
|
||||
});
|
||||
}
|
||||
|
||||
// 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 */ });
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
||||
// Warm the models.dev catalog cache (single writer; refreshed every 24h
|
||||
// while the app runs). Every consumer — catalog listings, the reasoning
|
||||
// capability gate — reads the on-disk cache only. Best-effort: failures
|
||||
// leave any existing cache in use and never block boot.
|
||||
startModelsDevRefresh();
|
||||
|
||||
// PostHog identify() is idempotent — call it on every startup so existing
|
||||
// signed-in installs (and every cold start of v0.3.4+) get re-identified.
|
||||
// Otherwise main-process events stay anonymous until the user re-signs-in.
|
||||
|
|
@ -479,109 +308,12 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
setupBrowserExtensions();
|
||||
|
||||
// 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')),
|
||||
});
|
||||
// ChatGPT subscription tokens at rest: same keychain-backed cipher.
|
||||
setChatGPTTokenCipher({
|
||||
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);
|
||||
});
|
||||
|
||||
// Resident app (Granola-style): register as an OS login item once, on the
|
||||
// first packaged run. After that the OS registry is the source of truth —
|
||||
// the Settings toggle writes it directly, and disabling the login item in
|
||||
// System Settings sticks because we never re-register on boot.
|
||||
if (app.isPackaged && !loadAppSettings().loginItemRegistered) {
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: true,
|
||||
...(process.platform === "win32" ? { args: ["--hidden"] } : {}),
|
||||
});
|
||||
saveAppSettings({ loginItemRegistered: true });
|
||||
} catch (error) {
|
||||
console.error("[LoginItem] Failed to register login item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
createWindow({ startHidden: wasLaunchedAtLogin() });
|
||||
|
||||
// Menu bar icon: open the app / start-stop meeting notes without the
|
||||
// window. If the renderer isn't ready to receive the toggle (window closed
|
||||
// or still loading), park it as a pending command the renderer drains on
|
||||
// mount — same pull pattern as pending deep links.
|
||||
createAppTray({
|
||||
openApp: showApp,
|
||||
toggleMeetingNotes: () => {
|
||||
const hadWindow = mainWindow !== null && !mainWindow.isDestroyed();
|
||||
showApp();
|
||||
const win = mainWindow;
|
||||
if (!hadWindow || !win || win.webContents.isLoading()) {
|
||||
markPendingToggleMeetingNotes();
|
||||
return;
|
||||
}
|
||||
win.webContents.send("app:toggleMeetingNotes", null);
|
||||
},
|
||||
});
|
||||
|
||||
// Ambient meeting detection (Granola-style): the mic-monitor helper +
|
||||
// running-app scan produce "Meeting detected" events; the popup asks
|
||||
// before anything records. Clicking "Take Notes" routes into the same
|
||||
// renderer flow as the calendar notification.
|
||||
initMeetingPopup({
|
||||
onTakeNotes: (meeting) => {
|
||||
showApp();
|
||||
// The user may have started recording between popup and click —
|
||||
// sending the take-notes flow then would toggle it OFF.
|
||||
if (isRecordingActive()) return;
|
||||
const payload = {
|
||||
event: meeting.calendarEvent ?? { summary: meeting.noteTitle },
|
||||
openMeeting: false,
|
||||
source: "detected",
|
||||
};
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
if (!win.isDestroyed()) win.webContents.send("app:takeMeetingNotes", payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
win.webContents.send("app:takeMeetingNotes", payload);
|
||||
},
|
||||
});
|
||||
initMeetingDetection({
|
||||
helperPath: path.join(__dirname, "mic-monitor"),
|
||||
onDetected: (meeting) => showMeetingPopup(meeting),
|
||||
// Call ended while recording (meeting app released the mic) — the
|
||||
// renderer stops capture and generates notes, same as a manual stop.
|
||||
onExternalCallEnded: () => {
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed() || win.webContents.isLoading()) return;
|
||||
win.webContents.send("meeting:externalCallEnded", null);
|
||||
},
|
||||
});
|
||||
createWindow();
|
||||
|
||||
// Start workspace watcher as a main-process service
|
||||
// Watcher runs independently and catches ALL filesystem changes:
|
||||
|
|
@ -593,81 +325,17 @@ 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();
|
||||
// Turn event spine: durable events of every turn (session, headless,
|
||||
// sub-agent) → renderer, for turnId-keyed live views.
|
||||
startTurnEventsWatcher();
|
||||
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();
|
||||
|
||||
// start live-note agent event watcher (forwards bus → renderer)
|
||||
startLiveNoteAgentWatcher();
|
||||
|
||||
// start bg-task agent event watcher (forwards bus → renderer)
|
||||
startBackgroundTaskAgentWatcher();
|
||||
|
||||
// start live-note scheduler (cron / window)
|
||||
initLiveNoteScheduler();
|
||||
|
||||
// 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)
|
||||
registerConsumer(liveNoteEventConsumer);
|
||||
registerConsumer(backgroundTaskEventConsumer);
|
||||
initEventProcessor();
|
||||
|
||||
// If the stored Google grant predates a scope change (only old scopes),
|
||||
// disconnect it now so the user re-connects with the current scopes before
|
||||
// any Google sync runs against the stale grant.
|
||||
await disconnectGoogleIfScopesStale();
|
||||
// start live-note event processor (consumes events/pending/, routes to matching live notes)
|
||||
initLiveNoteEventProcessor();
|
||||
|
||||
// start gmail sync
|
||||
initGmailSync();
|
||||
|
|
@ -684,6 +352,9 @@ app.whenReady().then(async () => {
|
|||
// start knowledge graph builder
|
||||
initGraphBuilder();
|
||||
|
||||
// start email labeling service
|
||||
initEmailLabeling();
|
||||
|
||||
// start note tagging service
|
||||
initNoteTagging();
|
||||
|
||||
|
|
@ -699,23 +370,23 @@ 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", () => {
|
||||
// Reveal the hidden/closed main window (login launches start hidden).
|
||||
showApp();
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// Resident app: with a tray present, keep running with no windows so
|
||||
// meeting detection/notifications stay alive (Granola-style). Without a
|
||||
// tray (creation failed), fall back to the platform-default quit.
|
||||
if (process.platform !== "darwin" && !hasTray()) {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
|
@ -725,17 +396,8 @@ 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
|
||||
}
|
||||
// Kill embedded terminal shells.
|
||||
disposeAllTerminals();
|
||||
shutdownAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to shut down cleanly:', error);
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
shutdownAnalytics().catch((error) => {
|
||||
console.error('[Analytics] Failed to flush on quit:', error);
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import { app, BrowserWindow, screen } from "electron";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js";
|
||||
|
||||
/**
|
||||
* The "Meeting detected — Take Notes?" popup: a small frameless panel in the
|
||||
* top-left corner of the display the user is on. A macOS panel (NSPanel) at
|
||||
* screen-saver level with fullscreen-auxiliary behavior, so it floats over
|
||||
* whatever the user is looking at — including a fullscreen Meet/Zoom — and
|
||||
* appears without stealing focus. Auto-dismisses after a while and never
|
||||
* records anything by itself: clicking "Take Notes" hands off to the main
|
||||
* window's existing take-meeting-notes flow.
|
||||
*/
|
||||
|
||||
// The window IS the card: exact card dimensions, card background, native
|
||||
// rounded corners + shadow. No transparency — macOS panels don't honor
|
||||
// `transparent: true` reliably and paint a grey backing slab instead.
|
||||
const POPUP_WIDTH = 376;
|
||||
const POPUP_HEIGHT = 48;
|
||||
// The popup renderer owns the real 45s countdown (it pauses on hover and
|
||||
// draws the progress line); this is only a crash-safety net so a wedged
|
||||
// renderer can't leave the popup on screen forever.
|
||||
const FALLBACK_DISMISS_MS = 180_000;
|
||||
|
||||
// Display names, Granola-style ("Chrome", not "Google Chrome").
|
||||
const SHORT_APP_NAMES: Record<string, string> = {
|
||||
"Google Chrome": "Chrome",
|
||||
"Microsoft Edge": "Edge",
|
||||
"Brave Browser": "Brave",
|
||||
"Microsoft Teams": "Teams",
|
||||
};
|
||||
|
||||
export interface MeetingPopupPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
hasCalendarEvent: boolean;
|
||||
}
|
||||
|
||||
let popupWin: BrowserWindow | null = null;
|
||||
let currentPayload: MeetingPopupPayload | null = null;
|
||||
let currentMeeting: DetectedMeeting | null = null;
|
||||
let dismissTimer: NodeJS.Timeout | null = null;
|
||||
let onTakeNotes: ((meeting: DetectedMeeting) => void) | null = null;
|
||||
|
||||
/** Main registers the take-notes handoff once at startup. */
|
||||
export function initMeetingPopup(handlers: { onTakeNotes: (meeting: DetectedMeeting) => void }): void {
|
||||
onTakeNotes = handlers.onTakeNotes;
|
||||
}
|
||||
|
||||
export function getMeetingPopupPayload(): MeetingPopupPayload | null {
|
||||
return currentPayload;
|
||||
}
|
||||
|
||||
export function handleMeetingPopupAction(action: "take-notes" | "dismiss"): void {
|
||||
const meeting = currentMeeting;
|
||||
closeMeetingPopup();
|
||||
if (action === "take-notes" && meeting) {
|
||||
onTakeNotes?.(meeting);
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMeetingPopup(): void {
|
||||
if (dismissTimer) {
|
||||
clearTimeout(dismissTimer);
|
||||
dismissTimer = null;
|
||||
}
|
||||
currentPayload = null;
|
||||
currentMeeting = null;
|
||||
if (popupWin && !popupWin.isDestroyed()) popupWin.destroy();
|
||||
popupWin = null;
|
||||
}
|
||||
|
||||
export function showMeetingPopup(meeting: DetectedMeeting): void {
|
||||
const eventSummary =
|
||||
typeof meeting.calendarEvent?.summary === "string"
|
||||
? (meeting.calendarEvent.summary as string).trim()
|
||||
: "";
|
||||
const appLabel = SHORT_APP_NAMES[meeting.appName] ?? meeting.appName;
|
||||
const payload: MeetingPopupPayload = {
|
||||
// Lean two-liner: "Meeting detected" / "Chrome" — or the event name
|
||||
// over the platform when a calendar event is happening now.
|
||||
title: eventSummary ? eventSummary : meeting.title,
|
||||
message: appLabel,
|
||||
hasCalendarEvent: Boolean(meeting.calendarEvent),
|
||||
};
|
||||
|
||||
// Replace any popup that's still up (stale detection loses to fresh).
|
||||
closeMeetingPopup();
|
||||
currentPayload = payload;
|
||||
currentMeeting = meeting;
|
||||
|
||||
// Top-left of the display the user is actually on (cursor display), not
|
||||
// necessarily the primary one.
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
|
||||
const workArea = display.workArea;
|
||||
const popupDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const preloadPath = app.isPackaged
|
||||
? path.join(popupDir, "../preload/dist/preload.js")
|
||||
: path.join(popupDir, "../../../preload/dist/preload.js");
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: POPUP_WIDTH,
|
||||
height: POPUP_HEIGHT,
|
||||
x: workArea.x + 24,
|
||||
// Sit a bit clear of the menu bar rather than hugging it.
|
||||
y: workArea.y + 44,
|
||||
// NSPanel (macOS): non-activating, and — unlike a regular window with
|
||||
// visibleOnFullScreen — can float over fullscreen Spaces without
|
||||
// turning Rowboat into an "agent" app that loses its Dock icon.
|
||||
...(process.platform === "darwin" ? { type: "panel" as const } : {}),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
show: false,
|
||||
backgroundColor: "#1d1d1d",
|
||||
hasShadow: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: preloadPath,
|
||||
},
|
||||
});
|
||||
// Screen-saver level + fullscreen-auxiliary: visible over fullscreen
|
||||
// meeting apps on every workspace — the whole point of the popup is to be
|
||||
// seen while the user is IN the meeting, wherever that is.
|
||||
win.setAlwaysOnTop(true, "screen-saver");
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
// visibleOnFullScreen flips the app's activation policy to "accessory":
|
||||
// the Dock icon disappears and the app can no longer take foreground
|
||||
// focus — clicking "Take Notes" then hands focus to whatever is next in
|
||||
// the window stack instead of Rowboat. Restore the policy immediately:
|
||||
// the popup keeps its fullscreen-auxiliary collection behavior (it's a
|
||||
// non-activating panel, same trick as Zoom's floating controls), while
|
||||
// Rowboat stays a regular app.
|
||||
if (process.platform === "darwin") {
|
||||
void app.dock?.show();
|
||||
}
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
if (win.isDestroyed()) return;
|
||||
win.webContents.send("meetingDetect:payload", payload);
|
||||
// showInactive: the user is in a meeting app right now — appearing
|
||||
// must not steal focus from it.
|
||||
win.showInactive();
|
||||
});
|
||||
win.on("closed", () => {
|
||||
if (popupWin === win) popupWin = null;
|
||||
});
|
||||
popupWin = win;
|
||||
|
||||
if (app.isPackaged) {
|
||||
win.loadURL("app://-/index.html#meeting-detected");
|
||||
} else {
|
||||
win.loadURL("http://localhost:5173/#meeting-detected");
|
||||
}
|
||||
|
||||
dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
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;
|
||||
|
|
@ -12,35 +11,11 @@ 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, 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;
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||
|
|
@ -18,9 +17,7 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
|
|||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
|
||||
function buildRedirectUri(port: number): string {
|
||||
return `http://localhost:${port}/oauth/callback`;
|
||||
}
|
||||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||
|
||||
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
||||
|
|
@ -117,15 +114,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get or create OAuth configuration for a provider.
|
||||
* `redirectUri` is required for DCR providers — it is the actual callback URI
|
||||
* (including port) that was just bound, so the registration and auth URL stay in sync.
|
||||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(
|
||||
provider: string,
|
||||
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
|
||||
credentialsOverride?: { clientId: string; clientSecret: string },
|
||||
): Promise<Configuration> {
|
||||
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
||||
const config = await getProviderConfig(provider);
|
||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
|
|
@ -166,20 +157,17 @@ async function getProviderConfiguration(
|
|||
);
|
||||
}
|
||||
|
||||
// Register new client with the actual redirect URI (port already bound)
|
||||
// Register new client
|
||||
const scopes = config.scopes || [];
|
||||
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
||||
config.discovery.issuer,
|
||||
[redirectUri],
|
||||
[REDIRECT_URI],
|
||||
scopes
|
||||
);
|
||||
|
||||
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
|
||||
const boundPort = new URL(redirectUri).port
|
||||
? parseInt(new URL(redirectUri).port, 10)
|
||||
: DEFAULT_CALLBACK_PORT;
|
||||
await clientRepo.saveClientRegistration(provider, registration, boundPort);
|
||||
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
|
||||
// Save registration for future use
|
||||
await clientRepo.saveClientRegistration(provider, registration);
|
||||
console.log(`[OAuth] ${provider}: DCR registration saved`);
|
||||
|
||||
return oauthConfig;
|
||||
}
|
||||
|
|
@ -201,37 +189,6 @@ async function getProviderConfiguration(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which port to start the OAuth callback server on for a DCR provider.
|
||||
*
|
||||
* If the provider has an existing registration, probes the port it was registered
|
||||
* on. If that port is still available, returns it so the existing client_id keeps
|
||||
* working. If it is blocked, clears the stale registration (forcing re-registration
|
||||
* on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export async function resolveStartPort(
|
||||
provider: string,
|
||||
clientRepo: IClientRegistrationRepo,
|
||||
): Promise<number> {
|
||||
const existingReg = await clientRepo.getClientRegistration(provider);
|
||||
if (!existingReg) return DEFAULT_CALLBACK_PORT;
|
||||
|
||||
const registeredPort = await clientRepo.getRegisteredPort(provider);
|
||||
try {
|
||||
// Probe — fixed-port (no fallback) so we know whether the exact registered port is free
|
||||
const probe = await createAuthServer(registeredPort, () => { /* probe */ });
|
||||
probe.server.close();
|
||||
console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`);
|
||||
return registeredPort;
|
||||
} catch {
|
||||
console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`);
|
||||
await clientRepo.clearClientRegistration(provider);
|
||||
return DEFAULT_CALLBACK_PORT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow for a provider
|
||||
*/
|
||||
|
|
@ -268,24 +225,30 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
}
|
||||
}
|
||||
|
||||
// For static-client providers (Google BYOK) the redirect URI is pre-registered
|
||||
// at the OAuth provider console on a fixed port — we must not scan.
|
||||
// For DCR providers, resolveStartPort handles the re-registration trap.
|
||||
const isStaticClient = providerConfig.client.mode === 'static';
|
||||
const startPort = isStaticClient
|
||||
? DEFAULT_CALLBACK_PORT
|
||||
: await resolveStartPort(provider, getClientRegistrationRepo());
|
||||
// Get or create OAuth configuration
|
||||
const config = await getProviderConfiguration(provider, credentials);
|
||||
|
||||
// --- Callback server ---
|
||||
// Declare `state` before the closure so the callback can close over its binding.
|
||||
// The variable is assigned below, before shell.openExternal, so it is always
|
||||
// set by the time any browser request arrives.
|
||||
let state = '';
|
||||
// Generate PKCE codes
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
const state = oauthClient.generateState();
|
||||
|
||||
// Get scopes from config
|
||||
const scopes = providerConfig.scopes || [];
|
||||
|
||||
// Store flow state
|
||||
activeFlows.set(state, { codeVerifier, provider, config });
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
});
|
||||
|
||||
// Create callback server
|
||||
let callbackHandled = false;
|
||||
|
||||
const { server, port: boundPort } = await createAuthServer(
|
||||
startPort,
|
||||
async (callbackUrl) => {
|
||||
const { server } = await createAuthServer(8080, async (callbackUrl) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
|
|
@ -345,11 +308,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
signedInUserId = billing.userId;
|
||||
analyticsIdentify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlanId,
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
analyticsCapture('user_signed_in', {
|
||||
plan: billing.subscriptionPlanId,
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
}
|
||||
|
|
@ -386,58 +349,18 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
activeFlow = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Static providers (Google BYOK) keep fixed-port behaviour to match the
|
||||
// pre-registered redirect URI at the provider's console. DCR providers
|
||||
// can fall back since we register the actual bound port below.
|
||||
{ fallback: !isStaticClient },
|
||||
);
|
||||
|
||||
// Server is bound. Any throw between here and `activeFlow = ...` would
|
||||
// leak the port — `cancelActiveFlow` only closes it once activeFlow is set.
|
||||
try {
|
||||
// TOCTOU guard: resolveStartPort probed the registered port and found it
|
||||
// free, but the port could have been grabbed between probe and real bind,
|
||||
// causing fallback to a different port. The cached client_id is registered
|
||||
// for the old port — clear it so getProviderConfiguration re-registers
|
||||
// with the actual bound port.
|
||||
if (!isStaticClient && boundPort !== startPort) {
|
||||
console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`);
|
||||
await getClientRegistrationRepo().clearClientRegistration(provider);
|
||||
}
|
||||
|
||||
const redirectUri = buildRedirectUri(boundPort);
|
||||
const config = await getProviderConfiguration(provider, redirectUri, credentials);
|
||||
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
state = oauthClient.generateState();
|
||||
|
||||
const scopes = providerConfig.scopes || [];
|
||||
activeFlows.set(state, { codeVerifier, provider, config });
|
||||
|
||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
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. 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.
|
||||
// Set timeout to clean up abandoned flows (2 minutes)
|
||||
// This prevents memory leaks if user never completes the OAuth flow
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlow?.state === state) {
|
||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||
cancelActiveFlow('timed_out');
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
}, 2 * 60 * 1000); // 2 minutes
|
||||
|
||||
// Store complete flow state for cleanup
|
||||
activeFlow = {
|
||||
provider,
|
||||
state,
|
||||
|
|
@ -448,16 +371,8 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
// Open in system browser (shares cookies/sessions with user's regular browser)
|
||||
shell.openExternal(authUrl.toString());
|
||||
|
||||
// Wait for callback (server will handle it)
|
||||
return { success: true };
|
||||
} catch (setupError) {
|
||||
// Post-bind setup failed — close the server so the port is released and
|
||||
// a retry isn't blocked by our own zombie listener.
|
||||
server.close();
|
||||
if (state) {
|
||||
activeFlows.delete(state);
|
||||
}
|
||||
throw setupError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth connection failed:', error);
|
||||
return {
|
||||
|
|
@ -516,7 +431,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
|
||||
try {
|
||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
||||
const res = await fetch(revokeUrl, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
||||
}
|
||||
|
|
@ -540,81 +455,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup migration for Google scope changes. When a connected Google grant was
|
||||
* issued before a scope was added (e.g. old installs on gmail.readonly that
|
||||
* never received gmail.modify), invalidate it so the user is prompted to
|
||||
* reconnect and re-grant with the current scopes. The currently-requested
|
||||
* scopes in the provider config are the source of truth: a grant missing any
|
||||
* of them is treated as stale.
|
||||
*
|
||||
* We revoke + clear the stale token but DELIBERATELY keep the provider entry
|
||||
* with an `error` set rather than calling disconnectProvider (which deletes the
|
||||
* whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your
|
||||
* accounts" alert and the connectors "Reconnect" row — key off this `error`
|
||||
* field, not off the connected flag. A fully deleted entry has no error and is
|
||||
* indistinguishable from "never connected", so no prompt would ever appear.
|
||||
*
|
||||
* Tokens with no recorded scopes (very old installs that never persisted them)
|
||||
* are also treated as stale. Safe to call on every startup — it's a no-op once
|
||||
* the grant covers all current scopes, and once invalidated the early return on
|
||||
* the missing token keeps it from re-running until the user reconnects.
|
||||
*/
|
||||
export async function disconnectGoogleIfScopesStale(): Promise<void> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const connection = await oauthRepo.read('google');
|
||||
|
||||
// Not connected (or already invalidated) — nothing to migrate.
|
||||
if (!connection.tokens) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerConfig = await getProviderConfig('google');
|
||||
const requiredScopes = providerConfig.scopes ?? [];
|
||||
if (requiredScopes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const granted = new Set(connection.tokens.scopes ?? []);
|
||||
const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
|
||||
if (missingScopes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
|
||||
'invalidating it so the user is prompted to reconnect with the new scopes.'
|
||||
);
|
||||
|
||||
// Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
|
||||
if (connection.mode === 'rowboat' && connection.tokens.access_token) {
|
||||
try {
|
||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) {
|
||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the stale token but keep the entry with an error so the reconnect
|
||||
// prompt fires (see the note above).
|
||||
await oauthRepo.upsert('google', {
|
||||
tokens: null,
|
||||
error: 'Google permissions changed. Please reconnect to continue.',
|
||||
});
|
||||
|
||||
// Nudge any already-open window to re-read state. The renderer's initial
|
||||
// mount also re-reads, so the prompt shows even if no window is up yet.
|
||||
emitOAuthEvent({ provider: 'google', success: false });
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Google scope migration check failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a provider (internal use only)
|
||||
* Refreshes token if expired
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import * as runsCore from '@x/core/dist/runtime/legacy/runs.js';
|
||||
import { bus } from '@x/core/dist/runtime/legacy/bus.js';
|
||||
import * as runsCore from '@x/core/dist/runs/runs.js';
|
||||
import { bus } from '@x/core/dist/runs/bus.js';
|
||||
|
||||
async function main() {
|
||||
const { id } = await runsCore.createRun({
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
import { app, Menu, Tray, nativeImage } from "electron";
|
||||
|
||||
/**
|
||||
* Menu bar / system tray presence (Granola-style resident app).
|
||||
*
|
||||
* The icon is the app glyph pre-rendered as a macOS "template" image
|
||||
* (pure black + alpha, derived from icons/icon.png: alpha = pixel
|
||||
* luminance, so the white sail becomes an opaque black shape and the
|
||||
* black rounded square becomes transparent). Embedded as base64 so the
|
||||
* tray never depends on asset paths that differ between dev and
|
||||
* packaged layouts. Template rendering makes macOS tint it correctly
|
||||
* in light/dark menu bars and while highlighted.
|
||||
*/
|
||||
const TRAY_ICON_18 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAABRElEQVR4nKySMS9EQRSF7773REEUlFpRiEg0Oj1RqTQqCiLRKNV+gkLvF5CoJRqJPyBEqyMURJB9zzl5Z3bvTuZtdjdu8mVm7sw9c+fOzWw0a4HMO3Ib3DJRaV0NK5QrqHLBM2AB/IAvOgrrn0EJ2pqvgg2N82AarIBX7jcJtSQyCXbALliKzryDJ83LIiEQnrAFTsBcOOxg3CN4C4FFQmQCnINN+b/BmPZDvTi/0T597SwS4dtvwTK4VPrj1mu5zl9oXQWBMDKTfXAPruSfBcfgwOqi03j5A1h0vo5QJ0W3Dr9GOwN7eiYzPASn/owXSgmEBmTfsLhT4NnqHvqMg2IrozkvewHXyvpIIj2xqYyajJncWf2bPuvGjFL76+ADbFu3WW0YIdaHvcYfWrOoLt4GeVquC3+t228jCfmzVb/b/sX+AAAA//+fzjrgAAAABklEQVQDAHsTRGNUkus5AAAAAElFTkSuQmCC";
|
||||
const TRAY_ICON_36 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC3klEQVR4nOyYu4sUQRDGa3fm8DgxUxB8ICqKqBj4QBOFMxMDRQUfgS/0D1A0MRQMTA1MxBcngpFmBiocgokgCopo4PnCSE193e74FV11XTtMz+zs9sLB3Qc/drenp/ubrqruuUtpmimlaaYZb6gBmvK9DbKiDoNUU+bIxEClEoqvRIxkOYbBcrAJ7ALzwATlFiVWyDQULYE1F2wE28E2sBYsJr8I58Fjc18UQw2hLYOOgJ1gHxgFSwru+SsmXsnvjjzq1ZA1wgOuBMfBQXJhUbUF7c8akraJWIZ4yVsyEIfhjBgZket6TRPa5mkmbd/AZ9PWk6GGmXAhuABOgTnSPikmmoH79F7+/Rr8IV+BtQ3xJFq2HJpLYkqNJNS5EkXbiVYb65kZt1XXkIZoAbhKLmGtkdRMFNrXMmOA+4zn2qdUtTGqma1gDKwAv2XgtOD+MkOaV5w/XAS/ijqmXZjZA+6QT9ph00erLDGTFpnitkn5fCRmbBpUGtLYHgU3pe0teAKegnVgL7kqI+lrjwlrKh+ue1Si0NPwICfBFXAX3CaXiP9MP16V3eSqbXOFKb32BawmF/ZCNQvaeDDOlWVgvRgbFzOJgSd5QO5YuEyd+5OaUnRzHBMzwTM0lIRD5FcjofBpnZDPIzZ1jnz12Qdk8ZGxBnykgv2nyhDLnthl0oOVjb0AG8itlOYnPxg/4C1wjALJrCp7/agykjfF/b+CI9QZNq2ww+BH1UBNiiPNkYfgHflQtuT7NfCe/KoHFfMFTZN6PthBblU4bLwq+ymwEeYVa4WsXspnW8Y/C75TSSJbDeIlf1Q+eUe/T24PK01kq9gv+fyAb8Aq8IncK+xPudZVkcQKmY6zhfzBeYBc/nQVKlXskJ0Wc4fAc6oRKlWMkOkKLCUXphPgBvmqqz1Yv9K95SL4AK73aiaWIRYfDYvInVO1w0QDMKTqywwr5k5dq5pCGsTf9n1p9h9WVfoPAAD//0eu+ckAAAAGSURBVAMAjdCu7L3gD4wAAAAASUVORK5CYII=";
|
||||
|
||||
interface TrayActions {
|
||||
openApp: () => void;
|
||||
toggleMeetingNotes: () => void;
|
||||
}
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let actions: TrayActions | null = null;
|
||||
let recording = false;
|
||||
|
||||
// Tray commands issued while the renderer wasn't ready to receive them
|
||||
// (window closed or still loading). Drained by the renderer on mount via
|
||||
// app:consumePendingTrayCommand — same pull pattern as pending deep links.
|
||||
let pendingToggleMeetingNotes = false;
|
||||
|
||||
export function markPendingToggleMeetingNotes(): void {
|
||||
pendingToggleMeetingNotes = true;
|
||||
}
|
||||
|
||||
export function consumePendingToggleMeetingNotes(): boolean {
|
||||
const value = pendingToggleMeetingNotes;
|
||||
pendingToggleMeetingNotes = false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildTrayIcon() {
|
||||
const icon = nativeImage.createEmpty();
|
||||
icon.addRepresentation({
|
||||
scaleFactor: 1,
|
||||
buffer: Buffer.from(TRAY_ICON_18, "base64"),
|
||||
});
|
||||
icon.addRepresentation({
|
||||
scaleFactor: 2,
|
||||
buffer: Buffer.from(TRAY_ICON_36, "base64"),
|
||||
});
|
||||
icon.setTemplateImage(true);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function createAppTray(trayActions: TrayActions): void {
|
||||
if (tray) return;
|
||||
actions = trayActions;
|
||||
|
||||
try {
|
||||
tray = new Tray(buildTrayIcon());
|
||||
} catch (error) {
|
||||
// Tray support can be missing (some Linux environments). The app just
|
||||
// behaves as before: no resident presence.
|
||||
console.error("[Tray] Failed to create tray:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
rebuildMenu();
|
||||
|
||||
// macOS opens the context menu on any click. On Windows/Linux a plain
|
||||
// left-click should open the app; the menu stays on right-click.
|
||||
if (process.platform !== "darwin") {
|
||||
tray.on("click", () => actions?.openApp());
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTray(): boolean {
|
||||
return tray !== null;
|
||||
}
|
||||
|
||||
export function isRecordingActive(): boolean {
|
||||
return recording;
|
||||
}
|
||||
|
||||
export function setTrayRecordingState(isRecording: boolean): void {
|
||||
if (recording === isRecording) return;
|
||||
recording = isRecording;
|
||||
rebuildMenu();
|
||||
if (isRecording) startWaveAnimation();
|
||||
else stopWaveAnimation();
|
||||
}
|
||||
|
||||
// --- Recording indicator: animated mini-waveform beside the tray icon ---
|
||||
// macOS renders tray titles to the right of the icon. Braille cells give
|
||||
// 1-dot-wide bars (two bars per character, four height steps each) — a slim
|
||||
// waveform, an unmissable "Rowboat is capturing this meeting" signal.
|
||||
|
||||
const WAVE_FRAME_MS = 300;
|
||||
const WAVE_BAR_COUNT = 5;
|
||||
// Dot bits for a bar of height 1–4 (index 0–3), built bottom-up. Two bars
|
||||
// per braille cell (left column: dots 7,3,2,1 — right column: dots 8,6,5,4)
|
||||
// keeps the columns tightly packed; the sine wave keeps every bar ≥1 dot so
|
||||
// no column ever reads as missing.
|
||||
const WAVE_LEFT_BITS = [0x40, 0x44, 0x46, 0x47];
|
||||
const WAVE_RIGHT_BITS = [0x80, 0xa0, 0xb0, 0xb8];
|
||||
// Radians per bar / per frame: together they make the crest travel smoothly
|
||||
// leftward across the five bars.
|
||||
const WAVE_SPATIAL_STEP = 1.1;
|
||||
const WAVE_PHASE_STEP = 0.9;
|
||||
|
||||
let waveTimer: NodeJS.Timeout | null = null;
|
||||
let wavePhase = 0;
|
||||
|
||||
function waveString(phase: number): string {
|
||||
const levels: number[] = [];
|
||||
for (let i = 0; i < WAVE_BAR_COUNT; i++) {
|
||||
const level = Math.round(1.5 + 1.5 * Math.sin(phase + i * WAVE_SPATIAL_STEP));
|
||||
levels.push(Math.min(3, Math.max(0, level)));
|
||||
}
|
||||
let out = "";
|
||||
for (let i = 0; i < levels.length; i += 2) {
|
||||
const left = WAVE_LEFT_BITS[levels[i]];
|
||||
const right = levels[i + 1] !== undefined ? WAVE_RIGHT_BITS[levels[i + 1]] : 0;
|
||||
out += String.fromCharCode(0x2800 + left + right);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function startWaveAnimation(): void {
|
||||
if (!tray || process.platform !== "darwin") return;
|
||||
stopWaveAnimation();
|
||||
waveTimer = setInterval(() => {
|
||||
if (!tray) return;
|
||||
wavePhase += WAVE_PHASE_STEP;
|
||||
tray.setTitle(` ${waveString(wavePhase)}`, { fontType: "monospaced" });
|
||||
}, WAVE_FRAME_MS);
|
||||
}
|
||||
|
||||
function stopWaveAnimation(): void {
|
||||
if (waveTimer) {
|
||||
clearInterval(waveTimer);
|
||||
waveTimer = null;
|
||||
}
|
||||
if (tray && process.platform === "darwin") tray.setTitle("");
|
||||
}
|
||||
|
||||
function rebuildMenu(): void {
|
||||
if (!tray) return;
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: "Open Rowboat", click: () => actions?.openApp() },
|
||||
recording
|
||||
? {
|
||||
label: "Stop recording and generate notes",
|
||||
click: () => actions?.toggleMeetingNotes(),
|
||||
}
|
||||
: {
|
||||
label: "Start meeting notes",
|
||||
click: () => actions?.toggleMeetingNotes(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Rowboat", click: () => app.quit() },
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
tray.setToolTip(recording ? "Rowboat — recording meeting" : "Rowboat");
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron";
|
||||
import { capture } from "@x/core/dist/analytics/posthog.js";
|
||||
import type { ipc } from "@x/shared";
|
||||
|
||||
export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"];
|
||||
|
||||
const REPO = "rowboatlabs/rowboat";
|
||||
const CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" };
|
||||
|
||||
function setStatus(next: Omit<UpdaterStatus, "version">): void {
|
||||
status = { version: status.version, ...next };
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send("updater:status", status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpdaterStatus(): UpdaterStatus {
|
||||
return status;
|
||||
}
|
||||
|
||||
// 32x32 green dot with a white ring (scratchpad-generated PNG). Windows'
|
||||
// counterpart of the macOS dock badge: overlays the taskbar icon while an
|
||||
// update is staged. Cleared implicitly — installing quits the process.
|
||||
const WIN_BADGE_DATA_URL =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABPUlEQVR42s1XOwoCMRC12CvkAhb2HmMvYS/kCgveQU9gYS17AKu1EKwXsdDCwtQWtk9GkiUbkv2RkAw8WLLJzEvmk8kMwCwmpixiAHIAHEAhweUYC0Ugk0Yq9Esl52a+CJAygfEi5NrJBOg4S5vm6+eOgzhh+zr+Qd805pCyyzUu4wsAta7l+X1j89hjeVljfl5ZQf9oDs01pJY6BxFgpnHapcuoC7TGQoINIdA6dn7bjTauQGst7ugkwH0Z7yDBXQQyPdqnHPtAdwg9Ra27pyDyZVzBCExuI9AUGYpk3wRIp1GsWgSY/rcr1aaCdBrCdAK5XmR8G1cwilWuE2j8T1UtFAHSbcaBIlCEiP6ebCiSIhDdBdGDMHoaRi9ESZTi6JdR9Os4iYYkiZYselOaRFuexMMkmadZEo/TYPgB7Se8LkyPD5UAAAAASUVORK5CYII=";
|
||||
|
||||
function showReadyBadge(): void {
|
||||
if (process.platform === "darwin") {
|
||||
// The window may be closed for days on macOS (app keeps running) — the
|
||||
// dock badge is the only surface that says "an update is waiting".
|
||||
app.dock?.setBadge("1");
|
||||
} else if (process.platform === "win32") {
|
||||
const badge = nativeImage.createFromDataURL(WIN_BADGE_DATA_URL);
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.setOverlayIcon(badge, "Update ready — restart to install");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-update, driving Electron's autoUpdater (Squirrel) against
|
||||
* update.electronjs.org directly. Events are forwarded to the renderer
|
||||
* (updater:status), which shows a "Restart to update" card once the update
|
||||
* is staged. By then Squirrel has already installed it — the card only asks
|
||||
* for the restart, Chrome-style. Must be called after app ready (it is —
|
||||
* from whenReady() in main.ts).
|
||||
*/
|
||||
export function initUpdater(): void {
|
||||
const version = app.getVersion();
|
||||
|
||||
if (!app.isPackaged) {
|
||||
status = { state: "disabled", version, reason: "dev" };
|
||||
return;
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
// Electron's autoUpdater doesn't support Linux (deb/zip installs).
|
||||
status = { state: "unsupported", version, reason: "platform" };
|
||||
return;
|
||||
}
|
||||
if (process.platform === "darwin" && !app.isInApplicationsFolder()) {
|
||||
// Squirrel.Mac swaps the .app bundle in place, which fails outside
|
||||
// /Applications (DMG mount, ~/Downloads). Don't wire the updater —
|
||||
// Settings > Help tells the user to move the app.
|
||||
status = { state: "unsupported", version, reason: "not-in-applications" };
|
||||
return;
|
||||
}
|
||||
|
||||
status = { state: "idle", version };
|
||||
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
setStatus({ state: "checking", lastCheckedAt: status.lastCheckedAt });
|
||||
});
|
||||
autoUpdater.on("update-available", () => {
|
||||
setStatus({ state: "downloading" });
|
||||
});
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
setStatus({ state: "idle", lastCheckedAt: Date.now() });
|
||||
});
|
||||
autoUpdater.on("update-downloaded", (_event, releaseNotes, releaseName) => {
|
||||
// macOS (Squirrel.Mac fed by update.electronjs.org) supplies both the
|
||||
// release name and the GitHub release body; Squirrel.Windows only the
|
||||
// name. When notes are missing the card shows a static fallback line.
|
||||
setStatus({
|
||||
state: "ready",
|
||||
newVersion: releaseName || undefined,
|
||||
releaseNotes: releaseNotes || undefined,
|
||||
});
|
||||
showReadyBadge();
|
||||
// Squirrel.Windows never carries notes, and Squirrel.Mac's copy is a
|
||||
// snapshot from download time — stale when the release body is edited
|
||||
// after publish (update.electronjs.org caching widens that window).
|
||||
// Refresh from the GitHub API; on failure the snapshot (or the card's
|
||||
// static fallback line) stands.
|
||||
void backfillReleaseNotes(releaseName || undefined);
|
||||
});
|
||||
autoUpdater.on("error", (err) => {
|
||||
setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt });
|
||||
capture("update_failed", { message: err.message });
|
||||
});
|
||||
|
||||
// update.electronjs.org serves both Squirrel dialects from one URL:
|
||||
// Squirrel.Mac GETs it as-is (204 = up to date, JSON = update; that legacy
|
||||
// format is serverType "default"), Squirrel.Windows appends /RELEASES.
|
||||
autoUpdater.setFeedURL({
|
||||
url: `https://update.electronjs.org/${REPO}/${process.platform}-${process.arch}/${version}`,
|
||||
serverType: "default",
|
||||
});
|
||||
// Check now and every 10 minutes, through the same guard as the manual
|
||||
// check: a tick is a no-op while a check/download is in flight or an
|
||||
// update is already staged.
|
||||
checkForUpdates();
|
||||
setInterval(checkForUpdates, CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the staged update's release notes with the current GitHub release
|
||||
* body. releaseName is "0.7.7" from Squirrel.Windows and "v0.7.7" from
|
||||
* Squirrel.Mac — normalize to the tag form.
|
||||
*/
|
||||
async function backfillReleaseNotes(releaseName: string | undefined): Promise<void> {
|
||||
if (!releaseName) return;
|
||||
try {
|
||||
const tag = `v${releaseName.replace(/^v/, "")}`;
|
||||
const res = await net.fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||
headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const { body } = (await res.json()) as { body?: string };
|
||||
const notes = body?.trim();
|
||||
// Re-check the state: the fetch raced user actions (quitAndInstall).
|
||||
if (notes && status.state === "ready" && status.newVersion === releaseName) {
|
||||
setStatus({ ...status, releaseNotes: notes });
|
||||
}
|
||||
} catch {
|
||||
// Offline or rate-limited — the Squirrel snapshot / fallback line stands.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "Check for updates". Only meaningful when idle or errored;
|
||||
* checking/downloading are already in flight and ready is already staged.
|
||||
* Returns the snapshot after initiating.
|
||||
*/
|
||||
export function checkForUpdates(): UpdaterStatus {
|
||||
if (status.state === "idle" || status.state === "error") {
|
||||
try {
|
||||
autoUpdater.checkForUpdates();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setStatus({ state: "error", error: error.message, lastCheckedAt: status.lastCheckedAt });
|
||||
capture("update_failed", { message: error.message });
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function quitAndInstallUpdate(): void {
|
||||
capture("update_restarted", { from: status.version, to: status.newVersion });
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
|
@ -7,8 +7,7 @@
|
|||
"build": "rm -rf dist && tsc && esbuild dist/preload.js --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload.bundle.js && mv dist/preload.bundle.js dist/preload.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x/shared": "workspace:*",
|
||||
"electron-chrome-extensions": "^4.9.0"
|
||||
"@x/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^39.2.7",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,6 @@
|
|||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
||||
import { injectBrowserAction } from 'electron-chrome-extensions/browser-action';
|
||||
import { ipc as ipcShared } from '@x/shared';
|
||||
|
||||
// Expose the <browser-action-list> custom element (extension action icons +
|
||||
// popups for the embedded browser pane). App documents only — this preload
|
||||
// is attached solely to the app window, but guard against it ever being
|
||||
// reused for remote content.
|
||||
if (location.protocol === 'app:' || location.origin === 'http://localhost:5173') {
|
||||
try {
|
||||
injectBrowserAction();
|
||||
} catch (error) {
|
||||
console.error('[preload] injectBrowserAction failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeChannels = ipcShared.InvokeChannels;
|
||||
type IPCChannels = ipcShared.IPCChannels;
|
||||
type SendChannels = ipcShared.SendChannels;
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
# Rowboat Design Language
|
||||
|
||||
Rowboat should feel like a command center for people who live in notes, agents, email, meetings, and files all day. The launch direction is quiet, fast, and prosumer: dense enough for repeated work, warm enough to feel personal, and explicit about what the AI is doing.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Calm density**
|
||||
Keep the interface compact and scannable. Use tighter rows, restrained borders, and low-contrast panels so users can keep many contexts open without the app feeling heavy.
|
||||
|
||||
2. **Command first**
|
||||
Primary actions should feel like instant commands, not marketing CTAs. Side navigation, search, model selection, and composer controls use compact icon-led affordances with clear hover and selected states.
|
||||
|
||||
3. **Visible work state**
|
||||
AI actions, sync, saving, meeting capture, and background tasks need clear status surfaces. Prefer small persistent indicators over large banners.
|
||||
|
||||
4. **Notes as the canvas**
|
||||
The editor and conversation stay visually dominant. Chrome is supportive, not decorative. Avoid nested cards and oversized empty states in work surfaces.
|
||||
|
||||
5. **Neutral precision**
|
||||
The palette follows the dev color system: white and graphite surfaces, black/white primary actions, neutral command tools, and reserved semantic colors for destructive and chart states.
|
||||
|
||||
## Tokens
|
||||
|
||||
- Radius: `8px` for controls and cards, smaller where density matters.
|
||||
- Backgrounds: dev defaults in light and dark mode.
|
||||
- Borders: one-step darker than surfaces, quiet enough to separate panels without tinting them.
|
||||
- Shadows: reserved for the composer, menus, dialogs, and active segmented controls.
|
||||
- Type: system sans with tabular-feeling OpenType features enabled; no negative tracking.
|
||||
- Accent use: primary and command affordances use the neutral dev palette. Extra hues are reserved for semantic states and charts.
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
- **Sidebar:** persistent workflow switcher with calm selected states. Quick-action icons use neutral ink from the dev palette.
|
||||
- **Titlebar/tabs:** slim, scan-first navigation. Active tabs get a bottom signal line, not a bulky filled pill.
|
||||
- **Composer:** the highest-emphasis control outside the active canvas. It is slightly raised, flat, bordered by the primary tone, and sharp enough to feel like an input terminal.
|
||||
- **Messages:** user messages are compact structured blocks; assistant messages remain full-width and readable.
|
||||
- **Status:** sync, saving, recording, and task activity stay small but always visible near the surface they affect.
|
||||
|
||||
## Launch Positioning
|
||||
|
||||
The visual story is: **Rowboat is the personal AI workspace for people whose work already spans meetings, mail, notes, browser tasks, and agents.** It should feel closer to a focused desktop tool than a chat website.
|
||||
|
|
@ -5,20 +5,10 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@eigenpal/docx-editor-react": "^1.0.3",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
|
@ -47,27 +37,15 @@
|
|||
"@tiptap/starter-kit": "3.22.4",
|
||||
"@x/preload": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"ai": "^7.0.22",
|
||||
"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",
|
||||
"nanoid": "^5.1.6",
|
||||
"posthog-js": "^1.332.0",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.7",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.41.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
|
@ -86,9 +64,6 @@
|
|||
},
|
||||
"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",
|
||||
|
|
@ -97,11 +72,9 @@
|
|||
"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",
|
||||
"vitest": "catalog:"
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* 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)}`)
|
||||
File diff suppressed because it is too large
Load diff
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.
|
|
@ -9,7 +9,6 @@ import { useState, useRef, useEffect } from "react";
|
|||
|
||||
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||
query: string;
|
||||
options?: string[];
|
||||
onResponse: (response: string) => void;
|
||||
isProcessing?: boolean;
|
||||
};
|
||||
|
|
@ -17,21 +16,17 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
|
|||
export const AskHumanRequest = ({
|
||||
className,
|
||||
query,
|
||||
options,
|
||||
onResponse,
|
||||
isProcessing = false,
|
||||
...props
|
||||
}: AskHumanRequestProps) => {
|
||||
const [response, setResponse] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const hasOptions = Array.isArray(options) && options.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
|
||||
if (!hasOptions) {
|
||||
// Auto-focus the textarea when component mounts
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [hasOptions]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = response.trim();
|
||||
|
|
@ -41,11 +36,6 @@ export const AskHumanRequest = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleOptionClick = (option: string) => {
|
||||
if (isProcessing) return;
|
||||
onResponse(option);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -75,26 +65,9 @@ export const AskHumanRequest = ({
|
|||
{query}
|
||||
</p>
|
||||
</div>
|
||||
{hasOptions ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options!.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOptionClick(option)}
|
||||
disabled={isProcessing}
|
||||
className="bg-background"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
@ -116,7 +89,6 @@ export const AskHumanRequest = ({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
export type AutoPermissionDecisionProps = ComponentProps<"div"> & {
|
||||
toolCall: z.infer<typeof ToolCallPart>;
|
||||
decision: "allow" | "deny";
|
||||
reason: string;
|
||||
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||
};
|
||||
|
||||
const fileActionLabels: Record<string, string> = {
|
||||
read: "Read file",
|
||||
list: "List folder",
|
||||
search: "Search files",
|
||||
write: "Write files",
|
||||
delete: "Delete path",
|
||||
};
|
||||
|
||||
export function AutoPermissionDecision({
|
||||
className,
|
||||
toolCall,
|
||||
decision,
|
||||
reason,
|
||||
permission,
|
||||
...props
|
||||
}: AutoPermissionDecisionProps) {
|
||||
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
||||
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||
? String(toolCall.arguments.command)
|
||||
: JSON.stringify(toolCall.arguments))
|
||||
: null;
|
||||
const filePermission = permission?.kind === "file" ? permission : null;
|
||||
const allowed = decision === "allow";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose mb-4 w-full rounded-md border",
|
||||
allowed
|
||||
? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30"
|
||||
: "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{allowed ? (
|
||||
<CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{allowed ? "Auto Allowed" : "Auto Denied"}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="bg-secondary text-foreground">
|
||||
<Terminal className="mr-1 size-3" />
|
||||
{toolCall.toolName}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
{command && (
|
||||
<div className="rounded-md border bg-background/50 p-3">
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre>
|
||||
</div>
|
||||
)}
|
||||
{filePermission && (
|
||||
<div className="space-y-3 rounded-md border bg-background/50 p-3">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Path{filePermission.paths.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">
|
||||
{filePermission.paths.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
|
|
@ -19,20 +20,10 @@ const ICON_STROKE_WIDTH = 2;
|
|||
|
||||
type ModelId = string;
|
||||
|
||||
// Our internal (flat) usage shape received over IPC, not the AI SDK's
|
||||
// restructured LanguageModelUsage (nested token details as of AI SDK 7).
|
||||
type UsageSummary = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
};
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: UsageSummary;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ 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(() => {
|
||||
|
|
@ -51,9 +50,7 @@ export const Conversation = ({
|
|||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
stickToBottomRef.current = atBottom;
|
||||
setIsAtBottom(atBottom);
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX);
|
||||
}, []);
|
||||
|
||||
const applyAnchorLayout = useCallback(
|
||||
|
|
@ -134,12 +131,7 @@ export const Conversation = ({
|
|||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const shouldStick = !anchorMessageId && stickToBottomRef.current;
|
||||
applyAnchorLayout(false);
|
||||
if (shouldStick) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateBottomState();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -186,7 +178,6 @@ export const Conversation = ({
|
|||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
stickToBottomRef.current = true;
|
||||
updateBottomState();
|
||||
}, [updateBottomState]);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -40,37 +38,6 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
|
|||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* Minimal copy-to-clipboard affordance for a message bubble. Invisible until
|
||||
* the surrounding Message (`.group`) is hovered.
|
||||
*/
|
||||
export const MessageCopyButton = ({
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
}}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
|
|
@ -82,7 +49,7 @@ export const MessageContent = ({
|
|||
data-slot="message-content"
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-2xl group-[.is-user]:rounded-tr-md group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-foreground",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
|
|
@ -349,7 +316,7 @@ export const MessageResponse = memo(
|
|||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 bidi-auto",
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -8,10 +9,9 @@ import {
|
|||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||
|
|
@ -22,15 +22,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
|
|||
onDeny?: () => void;
|
||||
isProcessing?: boolean;
|
||||
response?: 'approve' | 'deny' | null;
|
||||
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||
};
|
||||
|
||||
const fileActionLabels: Record<string, string> = {
|
||||
read: "Read file",
|
||||
list: "List folder",
|
||||
search: "Search files",
|
||||
write: "Write files",
|
||||
delete: "Delete path",
|
||||
};
|
||||
|
||||
export const PermissionRequest = ({
|
||||
|
|
@ -42,51 +33,26 @@ export const PermissionRequest = ({
|
|||
onDeny,
|
||||
isProcessing = false,
|
||||
response = null,
|
||||
permission,
|
||||
...props
|
||||
}: PermissionRequestProps) => {
|
||||
// Extract command from arguments if it's executeCommand
|
||||
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
||||
const command = toolCall.toolName === "executeCommand"
|
||||
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||
? String(toolCall.arguments.command)
|
||||
: JSON.stringify(toolCall.arguments))
|
||||
: null;
|
||||
const filePermission = permission?.kind === "file" ? permission : null;
|
||||
const externalAction =
|
||||
permission?.kind === "composio"
|
||||
? { label: "Composio action", detail: `${permission.toolSlug} (${permission.toolkitSlug})` }
|
||||
: permission?.kind === "mcp"
|
||||
? {
|
||||
label: "MCP tool",
|
||||
detail: permission.serverName
|
||||
? `${permission.toolName} on ${permission.serverName}`
|
||||
: permission.toolName,
|
||||
}
|
||||
: null;
|
||||
|
||||
const isResponded = response !== null;
|
||||
const isApproved = response === 'approve';
|
||||
|
||||
// Scope actions ("Allow for Session"/"Always Allow") render only when the
|
||||
// caller wires them: the legacy code-mode path persists grants, but the
|
||||
// turns path has no grant persistence yet and must not show dead buttons.
|
||||
const hasScopeActions =
|
||||
Boolean(onApproveSession || onApproveAlways) &&
|
||||
Boolean(command || filePermission);
|
||||
|
||||
// Once a response is chosen, collapse the details to just the header.
|
||||
// Users can click the header to expand them again.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showDetails = !isResponded || expanded;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose mb-4 w-full rounded-md border",
|
||||
isResponded
|
||||
? isApproved
|
||||
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
|
||||
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
|
||||
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
|
||||
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
|
||||
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
className
|
||||
)}
|
||||
|
|
@ -94,14 +60,17 @@ export const PermissionRequest = ({
|
|||
>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{!isResponded && (
|
||||
{isResponded ? (
|
||||
isApproved ? (
|
||||
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
|
||||
)
|
||||
) : (
|
||||
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
|
||||
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-sm text-foreground">
|
||||
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
||||
|
|
@ -111,15 +80,30 @@ export const PermissionRequest = ({
|
|||
</p>
|
||||
</div>
|
||||
{isResponded && (
|
||||
<ChevronDownIcon
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded ? "rotate-180" : "rotate-0"
|
||||
"shrink-0",
|
||||
isApproved
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{isApproved ? (
|
||||
<>
|
||||
<CheckIcon className="size-3 mr-1" />
|
||||
Approved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon className="size-3 mr-1" />
|
||||
Denied
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{showDetails && command && (
|
||||
{command && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Command
|
||||
|
|
@ -129,45 +113,7 @@ export const PermissionRequest = ({
|
|||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && filePermission && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Action
|
||||
</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Path{filePermission.paths.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
||||
{filePermission.paths.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Approval Scope
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
||||
{filePermission.pathPrefix}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && externalAction && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
{externalAction.label}
|
||||
</p>
|
||||
<p className="text-xs font-mono font-medium text-foreground break-all">
|
||||
{externalAction.detail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && !command && !filePermission && toolCall.arguments && (
|
||||
{!command && toolCall.arguments && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Arguments
|
||||
|
|
@ -187,12 +133,12 @@ export const PermissionRequest = ({
|
|||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={isProcessing}
|
||||
className={cn("flex-1", hasScopeActions && "rounded-r-none")}
|
||||
className={cn("flex-1", command && "rounded-r-none")}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
Approve
|
||||
</Button>
|
||||
{hasScopeActions && (
|
||||
{command && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,6 @@ export const PromptInputTextarea = ({
|
|||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
dir="auto"
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-sm text-transparent"
|
||||
>
|
||||
{mentionHighlights.segments.map((segment, index) =>
|
||||
|
|
@ -1181,7 +1180,6 @@ export const PromptInputTextarea = ({
|
|||
)}
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
className={cn("relative z-10 !p-0 field-sizing-content max-h-48 min-h-10", className)}
|
||||
name="message"
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleCheck,
|
||||
LoaderIcon,
|
||||
ShieldCheckIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
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, getToolErrorText, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -51,68 +48,51 @@ const ToolCode = ({
|
|||
</pre>
|
||||
);
|
||||
|
||||
export type ToolAutoPermissionDetail = {
|
||||
decision: "allow";
|
||||
reason: string;
|
||||
};
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible> & {
|
||||
autoPermissionDetail?: ToolAutoPermissionDetail;
|
||||
};
|
||||
|
||||
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
|
||||
const toolCard = (
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
autoPermissionDetail
|
||||
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
|
||||
className
|
||||
)}
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
/>
|
||||
);
|
||||
|
||||
if (!autoPermissionDetail) return toolCard;
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-4 w-full">
|
||||
{toolCard}
|
||||
<div className="mt-1 flex justify-end px-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
|
||||
Auto-approved
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" className="max-w-sm">
|
||||
{autoPermissionDetail.reason}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
/** Hide the leading status icon (used for child rows inside a tool group). */
|
||||
hideLeadIcon?: boolean;
|
||||
};
|
||||
|
||||
// Lead icon shown to the left of the tool label: spinner while running, a
|
||||
// green check when done, a red cross on error. Shared by ToolHeader (single
|
||||
// tools) and the tool-call group.
|
||||
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => {
|
||||
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />;
|
||||
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
|
||||
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
|
|
@ -120,7 +100,6 @@ export const ToolHeader = ({
|
|||
title,
|
||||
type,
|
||||
state,
|
||||
hideLeadIcon,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
||||
|
|
@ -128,13 +107,13 @@ export const ToolHeader = ({
|
|||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{!hideLeadIcon && getLeadIcon(state)}
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||
title={displayTitle}
|
||||
|
|
@ -142,7 +121,10 @@ export const ToolHeader = ({
|
|||
{displayTitle}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
};
|
||||
|
|
@ -152,7 +134,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
|||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -265,48 +247,41 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
|||
const isCompleted = state === 'output-available' || state === 'output-error'
|
||||
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
||||
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
||||
const toolCount = group.items.length
|
||||
const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}`
|
||||
const actions = isCompleted ? getToolActionsSummary(group.items) : ''
|
||||
// Plain string used as the AnimatePresence key + tooltip; the rendered node
|
||||
// shows the action summary in a lighter gray than the "Ran N tools" prefix.
|
||||
const summaryText = isCompleted
|
||||
? `${ranLabel} · ${actions}`
|
||||
const summary = isCompleted
|
||||
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
|
||||
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
|
||||
const summaryNode: ReactNode = isCompleted
|
||||
? <>{ranLabel} <span className="font-normal text-muted-foreground">{`· ${actions}`}</span></>
|
||||
: summaryText
|
||||
|
||||
const leadIcon = getLeadIcon(state)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||
className="not-prose mb-4 w-full rounded-md border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{leadIcon}
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={summaryText}
|
||||
key={summary}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
||||
title={summaryText}
|
||||
title={summary}
|
||||
>
|
||||
{summaryNode}
|
||||
{summary}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
||||
<CollapsibleContent className="border-t">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{group.items.map((tool) => {
|
||||
const toolState = toToolState(tool.status)
|
||||
|
|
@ -316,20 +291,18 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
|||
key={tool.id}
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
||||
className="mb-0 rounded-[20px] border-border/60 bg-transparent hover:border-border/60"
|
||||
className="mb-0 border-border/60"
|
||||
>
|
||||
<ToolHeader
|
||||
title={getToolDisplayName(tool)}
|
||||
type={`tool-${tool.name}`}
|
||||
state={toolState}
|
||||
className="text-muted-foreground"
|
||||
hideLeadIcon
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
input={tool.input as ToolUIPart["input"]}
|
||||
output={tool.result as ToolUIPart["output"]}
|
||||
errorText={getToolErrorText(tool)}
|
||||
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
interface WebSearchResultProps {
|
||||
query: string;
|
||||
|
|
@ -21,220 +19,40 @@ interface WebSearchResultProps {
|
|||
title?: string;
|
||||
}
|
||||
|
||||
// How long each fetched website stays on the rolling header before the
|
||||
// next one slides in. Kept slow enough to read the domain + title.
|
||||
const ROLL_INTERVAL_MS = 700;
|
||||
|
||||
// How many favicons to show in the settled stack before the rest collapse
|
||||
// into a "+N" chip. The text names this many domains too, so the chip count
|
||||
// (total - MAX_STACK) lines up with the "and N others" in the summary.
|
||||
const MAX_STACK = 3;
|
||||
|
||||
function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function faviconUrl(domain: string, size = 32): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
|
||||
}
|
||||
|
||||
// Collapse the result list into unique domains, preserving order.
|
||||
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const result of results) {
|
||||
const domain = getDomain(result.url);
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
out.push(domain);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Summary with text hierarchy: "Searched" + "and N others" are secondary
|
||||
// weight/color, the domain names are primary text at medium weight.
|
||||
function buildSearchedSummary(domains: string[]): React.ReactNode {
|
||||
const muted = "font-normal text-muted-foreground";
|
||||
const name = (d: string) => <span className="font-medium text-foreground">{d}</span>;
|
||||
if (domains.length === 1) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (domains.length === 2) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}> and </span>
|
||||
{name(domains[1])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const others = domains.length - 2;
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}>, </span>
|
||||
{name(domains[1])}
|
||||
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type RollPhase = "searching" | "rolling" | "settled";
|
||||
|
||||
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
||||
const isRunning = status === "pending" || status === "running";
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const domains = useMemo(() => uniqueDomains(results), [results]);
|
||||
|
||||
// Drive the one-shot rolling reveal. Results arrive all at once, so we
|
||||
// simulate "fetching one site at a time" by stepping through them with the
|
||||
// same slide animation the tool group uses, then settle on a summary.
|
||||
// `settled` is seeded from the initial status so a card loaded already-
|
||||
// complete from history skips straight to the summary (no roll).
|
||||
const [settled, setSettled] = useState(() => !isRunning);
|
||||
const [rollIndex, setRollIndex] = useState(0);
|
||||
|
||||
// Phase is fully derived: searching while the tool runs, rolling once
|
||||
// results land, then settled. No setState-in-effect needed for transitions.
|
||||
const phase: RollPhase = isRunning
|
||||
? "searching"
|
||||
: !settled && results.length > 0
|
||||
? "rolling"
|
||||
: "settled";
|
||||
|
||||
// Warm the browser cache for every favicon the moment results arrive, so
|
||||
// each icon is already loaded by the time its row rolls in (~700ms each).
|
||||
// Without this the network fetch lags the text and rows flash icon-less.
|
||||
useEffect(() => {
|
||||
for (const result of results) {
|
||||
const img = new Image();
|
||||
img.src = faviconUrl(getDomain(result.url));
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
// Advance the roll, then settle after the last site has had its moment.
|
||||
// setState only fires inside the timeout callback, never synchronously.
|
||||
useEffect(() => {
|
||||
if (phase !== "rolling") return;
|
||||
const isLast = rollIndex >= results.length - 1;
|
||||
const timer = setTimeout(
|
||||
() => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)),
|
||||
ROLL_INTERVAL_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [phase, rollIndex, results.length]);
|
||||
|
||||
// Build the content for the compact (collapsed) header line. Each distinct
|
||||
// value gets a unique key so AnimatePresence runs the slide transition.
|
||||
let headerKey: string;
|
||||
let headerContent: React.ReactNode;
|
||||
if (phase === "searching") {
|
||||
headerKey = "searching";
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin" />
|
||||
<span className="truncate">Searching the web…</span>
|
||||
</span>
|
||||
);
|
||||
} else if (phase === "rolling") {
|
||||
const result = results[rollIndex];
|
||||
const domain = getDomain(result.url);
|
||||
headerKey = `roll-${rollIndex}`;
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<img src={faviconUrl(domain)} alt="" className="size-4 shrink-0 rounded-sm bg-muted/60" />
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
<span className="text-muted-foreground/50"> · </span>
|
||||
<span>{result.title}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
headerKey = "settled";
|
||||
const stack = domains.slice(0, MAX_STACK);
|
||||
// Chip count matches the "and N others" in the text (total minus the 2
|
||||
// named domains), shown only when there are sites beyond the stack.
|
||||
const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0;
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{domains.length > 0 ? (
|
||||
<span className="flex shrink-0 items-center">
|
||||
{stack.map((domain, i) => (
|
||||
<img
|
||||
key={domain}
|
||||
src={faviconUrl(domain)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0"
|
||||
style={{ zIndex: stack.length - i }}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{domains.length > 0 ? buildSearchedSummary(domains) : title}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
||||
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={headerKey}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex items-center text-left font-medium text-sm"
|
||||
>
|
||||
{headerContent}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{phase === "settled" && domains.length > 0 && (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{domains.length} source{domains.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
{/* Query */}
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Query + result count */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{query}</span>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
{results.length > 0 && (
|
||||
|
|
@ -255,7 +73,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={faviconUrl(domain)}
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||
alt=""
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
|
|
@ -270,14 +88,21 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — only while the search is still running. */}
|
||||
{isRunning && (
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||
<span>Done</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,322 +0,0 @@
|
|||
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'
|
||||
import { unpinApp } from '@/lib/pinned-apps'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
|
||||
// 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 })
|
||||
unpinApp(folder)
|
||||
onClose()
|
||||
})
|
||||
|
||||
// Local apps aren't "installed", so they get delete instead of uninstall.
|
||||
const doDelete = () => runAction('delete', async () => {
|
||||
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
|
||||
const publishNote = app?.publish ? '\n\nThe published copy (GitHub repo + catalog listing) is not touched.' : ''
|
||||
if (!window.confirm(`Delete this app? The whole folder, including data/, is removed from this machine.${publishNote}${agentNote}`)) return
|
||||
await window.ipc.invoke('apps:delete', { folder })
|
||||
unpinApp(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 } })
|
||||
analytics.bgAgentToggled(active)
|
||||
} catch {
|
||||
setReloadNonce((n) => n + 1) // revert to truth on failure
|
||||
}
|
||||
}
|
||||
|
||||
const runAgent = async (slug: string) => {
|
||||
analytics.bgAgentRunClicked()
|
||||
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 can’t 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>
|
||||
) : (
|
||||
<button type="button" disabled={busyAction !== null} onClick={() => void doDelete()}
|
||||
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" /> Delete
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { PanelLeft, PanelLeftClose, Plus, RefreshCw } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { AppFrame } from '@/components/apps/app-frame'
|
||||
import { CatalogTab } from '@/components/apps/catalog'
|
||||
import { themeForIndex, patternFor } from '@/components/apps/card-theme'
|
||||
import { getPinnedApps, onPinnedAppsChanged, pinApp, unpinApp } from '@/lib/pinned-apps'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
|
||||
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
|
||||
// AppSummary-driven; click opens the app full-height on its own origin.
|
||||
|
||||
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:34px 30px 48px; }
|
||||
.ma-h1 { font-size: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-hint { font-size:12.5px; color:var(--ma-sub); margin:-6px 0 clamp(12px,1.8cqw,16px); }
|
||||
.ma-welcome {
|
||||
border:1px solid var(--ma-border); border-radius:12px; padding:12px 16px;
|
||||
font-size:13.5px; color:var(--ma-desc); margin-bottom:clamp(14px,2cqw,20px);
|
||||
background:color-mix(in srgb, var(--ma-title) 4%, transparent);
|
||||
}
|
||||
.ma-welcome button { color:var(--ma-title); font-weight:600; text-decoration:underline; text-underline-offset:3px; background:none; border:none; padding:0; font-size:inherit; cursor:pointer; }
|
||||
.ma-owner { font-size:12px; color:var(--ma-sub); margin:-4px 0 8px; }
|
||||
.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, isPinned, onTogglePin }: {
|
||||
app: rowboatApp.AppSummary
|
||||
index: number
|
||||
onOpen: () => void
|
||||
isPinned: boolean
|
||||
onTogglePin: () => void
|
||||
}) {
|
||||
const theme = themeForIndex(index)
|
||||
const pattern = patternFor(app.folder)
|
||||
const invalid = app.status === 'invalid'
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<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>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onTogglePin}>
|
||||
{isPinned ? <PanelLeftClose className="mr-2 size-3.5" /> : <PanelLeft className="mr-2 size-3.5" />}
|
||||
{isPinned ? 'Remove from sidebar' : 'Add to sidebar'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||
initialAppFolder?: string | null
|
||||
initialVersion?: number
|
||||
onNewApp?: () => void
|
||||
} = {}) {
|
||||
// null = auto: land on "My apps" normally, but fall through to the catalog
|
||||
// until the user has an app of their own. An explicit tab click wins.
|
||||
const [tab, setTab] = useState<'mine' | 'catalog' | null>(null)
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
|
||||
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
||||
const [appsLoaded, setAppsLoaded] = useState(false)
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
const [pinnedFolders, setPinnedFolders] = useState<string[]>(() => getPinnedApps())
|
||||
|
||||
useEffect(() => onPinnedAppsChanged(setPinnedFolders), [])
|
||||
|
||||
// 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)
|
||||
setAppsLoaded(true)
|
||||
// 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))
|
||||
// Prune sidebar pins for apps that no longer exist (uninstalled via
|
||||
// the copilot or another window) — but only off an authoritative
|
||||
// list, never while the apps server is down.
|
||||
if (r.serverRunning) {
|
||||
const live = new Set(r.apps.map((a) => a.folder))
|
||||
for (const f of getPinnedApps()) if (!live.has(f)) unpinApp(f)
|
||||
}
|
||||
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)} />
|
||||
}
|
||||
|
||||
const noOwnApps = appsLoaded && apps.length === 0
|
||||
const activeTab = tab ?? (noOwnApps ? 'catalog' : 'mine')
|
||||
|
||||
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${activeTab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
||||
<button type="button" className={`ma-tab${activeTab === '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>
|
||||
)}
|
||||
|
||||
{!appsLoaded && !serverError ? null : activeTab === 'catalog' ? (
|
||||
<>
|
||||
{noOwnApps && (
|
||||
<div className="ma-welcome">
|
||||
You don't have any apps of your own yet. Install one from this catalog, or{' '}
|
||||
<button type="button" onClick={onNewApp}>build your own</button>.
|
||||
</div>
|
||||
)}
|
||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{apps.length > 0 && (
|
||||
<p className="ma-hint">Tip: right-click an app to add it to the sidebar for quick access.</p>
|
||||
)}
|
||||
<div className="ma-grid">
|
||||
{apps.map((app, i) => (
|
||||
<Card
|
||||
key={app.folder}
|
||||
app={app}
|
||||
index={i}
|
||||
onOpen={() => setSelectedFolder(app.folder)}
|
||||
isPinned={pinnedFolders.includes(app.folder)}
|
||||
onTogglePin={() => (pinnedFolders.includes(app.folder) ? unpinApp(app.folder) : pinApp(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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// Deterministic accent/pattern assignment shared by the "My apps" grid and
|
||||
// the catalog grid, so both render the same card visual language.
|
||||
|
||||
export type CardTheme = { accent: string; glow: string }
|
||||
|
||||
const THEMES: CardTheme[] = [
|
||||
{ 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)
|
||||
}
|
||||
|
||||
export const themeForIndex = (i: number): CardTheme => THEMES[i % THEMES.length]
|
||||
export const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length]
|
||||
|
|
@ -1,445 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { themeForIndex, patternFor } from '@/components/apps/card-theme'
|
||||
|
||||
// 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())
|
||||
// GitHub star counts rank the list; `starred` is the signed-in user's set.
|
||||
const [stars, setStars] = useState<Record<string, number>>({})
|
||||
const [starred, setStarred] = useState<Record<string, boolean>>({})
|
||||
|
||||
const loadStars = async (recs: rowboatApp.RegistryRecord[]) => {
|
||||
if (recs.length === 0) return
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:catalogStars', { repos: recs.map((x) => x.repo) })
|
||||
setStars((prev) => ({ ...prev, ...r.stars }))
|
||||
setStarred((prev) => ({ ...prev, ...r.starred }))
|
||||
} catch { /* unranked list is fine */ }
|
||||
}
|
||||
|
||||
const toggleStar = async (repo: string) => {
|
||||
const next = !starred[repo]
|
||||
// Optimistic; revert on failure.
|
||||
setStarred((prev) => ({ ...prev, [repo]: next }))
|
||||
setStars((prev) => ({ ...prev, [repo]: Math.max(0, (prev[repo] ?? 0) + (next ? 1 : -1)) }))
|
||||
try {
|
||||
await window.ipc.invoke('apps:star', { repo, star: next })
|
||||
} catch (e) {
|
||||
setStarred((prev) => ({ ...prev, [repo]: !next }))
|
||||
setStars((prev) => ({ ...prev, [repo]: Math.max(0, (prev[repo] ?? 0) + (next ? -1 : 1)) }))
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg.includes('not_signed_in')
|
||||
? 'Starring uses your GitHub account — sign in once via any app’s Publish flow, then try again.'
|
||||
: msg)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
void loadStars(r.records)
|
||||
} 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)
|
||||
void loadStars(r.records)
|
||||
} catch { /* keep current list */ }
|
||||
}
|
||||
|
||||
// Rank by stars (unknown counts sink), name as the stable tiebreak.
|
||||
const ranked = [...records].sort((a, b) =>
|
||||
((stars[b.repo] ?? -1) - (stars[a.repo] ?? -1)) || a.name.localeCompare(b.name))
|
||||
|
||||
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="ma-grid">
|
||||
{ranked.map((r, i) => {
|
||||
const theme = themeForIndex(i)
|
||||
const installedFolder = installedByName.get(r.name)
|
||||
return (
|
||||
<div
|
||||
key={r.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => installedFolder ? onInstalled(installedFolder) : void startInstall(r.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (installedFolder) onInstalled(installedFolder)
|
||||
else void startInstall(r.name)
|
||||
}
|
||||
}}
|
||||
className={`ma-card ma-pat-${patternFor(r.name)}`}
|
||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||
>
|
||||
<div className="ma-top">
|
||||
{installedFolder && <span className="ma-badge">INSTALLED</span>}
|
||||
<button type="button"
|
||||
title={starred[r.repo] ? 'Unstar on GitHub' : 'Star on GitHub'}
|
||||
onClick={(e) => { e.stopPropagation(); void toggleStar(r.repo) }}
|
||||
className={`flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium hover:bg-foreground/10 ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}>
|
||||
<Star className={`size-3.5 ${starred[r.repo] ? 'fill-current' : ''}`} />
|
||||
{stars[r.repo] ?? '—'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ma-title">{r.name}</div>
|
||||
<div className="ma-owner">by {r.owner}</div>
|
||||
<div className="ma-desc">{r.description || 'No description.'}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-lastrun">{r.repo}</span>
|
||||
{installedFolder ? (
|
||||
<button type="button" title="Installed — open it"
|
||||
onClick={(e) => { e.stopPropagation(); onInstalled(installedFolder) }}
|
||||
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" /> Open
|
||||
</button>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); 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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, FolderOpen, Pencil, Trash2 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
||||
|
|
@ -103,18 +103,9 @@ type BasesViewProps = {
|
|||
rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
}
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('mac')) return 'Finder'
|
||||
if (platform.includes('win')) return 'Explorer'
|
||||
return 'File Manager'
|
||||
}
|
||||
|
||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
return nodes.flatMap((n) =>
|
||||
n.kind === 'file' && n.name.endsWith('.md')
|
||||
|
|
@ -466,7 +457,7 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
|
|||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 border-b border-border pr-4 py-2 flex items-center gap-3">
|
||||
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
|
|
@ -884,7 +875,7 @@ function NoteRow({
|
|||
|
||||
const row = (
|
||||
<tr
|
||||
className="border-b border-black/10 dark:border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
|
|
@ -928,10 +919,6 @@ function NoteRow({
|
|||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions?.revealInFileManager(note.path, false)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {getFileManagerName()}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,67 +0,0 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { BillingErrorMatch } from "@/lib/billing-error"
|
||||
import * as analytics from "@/lib/analytics"
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
appUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface BillingErrorDialogProps {
|
||||
open: boolean
|
||||
match: BillingErrorMatch | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
window.ipc
|
||||
.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && match) analytics.billingErrorShown(match.kind)
|
||||
}, [open, match])
|
||||
|
||||
if (!match) return null
|
||||
|
||||
const handleUpgrade = () => {
|
||||
analytics.billingUpgradeClicked(match.kind)
|
||||
if (appUrl) window.open(`${appUrl}?intent=upgrade`)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{match.title}</DialogTitle>
|
||||
<DialogDescription>{match.subtitle}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade} disabled={!appUrl}>
|
||||
{match.cta}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,37 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
|
||||
|
||||
// Custom element provided by electron-chrome-extensions (injected via the
|
||||
// preload script): a row of extension action icons for the given session
|
||||
// partition, with popup handling built in. Type names resolve against the
|
||||
// react module's own scope inside this augmentation.
|
||||
declare module 'react' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'browser-action-list': import('react').DetailedHTMLProps<
|
||||
import('react').HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
> & {
|
||||
partition?: string
|
||||
alignment?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import type { DisplayMediaRequest, DisplayMediaSource, 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'
|
||||
|
||||
/**
|
||||
|
|
@ -116,197 +86,9 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Source picker for getDisplayMedia() requests raised by pages in the
|
||||
* embedded browser (e.g. Google Meet "Present now"). Mirrors Chrome's share
|
||||
* dialog: pick a screen or window, optionally include system audio (screens
|
||||
* only — loopback capture is system-wide, so it isn't offered per-window).
|
||||
*/
|
||||
function BrowserScreenSharePickerDialog({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
request: DisplayMediaRequest
|
||||
onSubmit: (sourceId: string, audio: boolean) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [shareAudio, setShareAudio] = useState(false)
|
||||
|
||||
const screens = request.sources.filter((source) => source.kind === 'screen')
|
||||
const windows = request.sources.filter((source) => source.kind === 'window')
|
||||
const selected = request.sources.find((source) => source.id === selectedId) ?? null
|
||||
const audioAvailable = selected?.kind === 'screen'
|
||||
|
||||
const renderSources = (sources: DisplayMediaSource[]) => (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(source.id)}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-1.5 rounded-md border p-1.5 text-left transition-colors',
|
||||
source.id === selectedId
|
||||
? 'border-ring bg-accent'
|
||||
: 'border-border hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-20 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
{source.thumbnailDataUrl ? (
|
||||
<img
|
||||
src={source.thumbnailDataUrl}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{source.appIconDataUrl ? (
|
||||
<img src={source.appIconDataUrl} alt="" className="size-3.5 shrink-0" />
|
||||
) : null}
|
||||
<span className="truncate text-xs text-foreground">{source.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onCancel() }}>
|
||||
<DialogContent className="w-[min(36rem,calc(100%-2rem))] max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share your screen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose what to share with this site.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[50vh] flex-col gap-3 overflow-y-auto pr-1">
|
||||
{screens.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Screens</span>
|
||||
{renderSources(screens)}
|
||||
</div>
|
||||
)}
|
||||
{windows.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Windows</span>
|
||||
{renderSources(windows)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
audioAvailable ? 'text-foreground' : 'text-muted-foreground/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={audioAvailable && shareAudio}
|
||||
disabled={!audioAvailable}
|
||||
onChange={(e) => setShareAudio(e.target.checked)}
|
||||
/>
|
||||
Also share system audio
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selected}
|
||||
onClick={() => {
|
||||
if (!selected) return
|
||||
onSubmit(selected.id, audioAvailable && shareAudio)
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</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 [displayMediaQueue, setDisplayMediaQueue] = useState<DisplayMediaRequest[]>([])
|
||||
|
||||
const activeTabIdRef = useRef<string | null>(null)
|
||||
const addressFocusedRef = useRef(false)
|
||||
|
|
@ -339,92 +121,6 @@ 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
|
||||
|
||||
// Same lifecycle as the auth queue: push on request, prune on main-side
|
||||
// resolution (timeout / window teardown), cancel leftovers on unmount so
|
||||
// the main-process callbacks and timers are freed immediately.
|
||||
const displayMediaQueueRef = useRef<DisplayMediaRequest[]>([])
|
||||
useEffect(() => {
|
||||
displayMediaQueueRef.current = displayMediaQueue
|
||||
}, [displayMediaQueue])
|
||||
|
||||
useEffect(() => {
|
||||
const offRequest = window.ipc.on('browser:displayMediaRequest', (incoming) => {
|
||||
setDisplayMediaQueue((queue) => [...queue, incoming as DisplayMediaRequest])
|
||||
})
|
||||
const offResolved = window.ipc.on('browser:displayMediaResolved', (incoming) => {
|
||||
const { requestId } = incoming as { requestId: string }
|
||||
setDisplayMediaQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
})
|
||||
return () => {
|
||||
offRequest()
|
||||
offResolved()
|
||||
for (const request of displayMediaQueueRef.current) {
|
||||
void window.ipc.invoke('browser:displayMediaResponse', { requestId: request.requestId })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const respondToDisplayMedia = useCallback(
|
||||
(requestId: string, choice: { sourceId: string; audio: boolean } | null) => {
|
||||
setDisplayMediaQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
// Omit sourceId to cancel; include it to share the chosen source.
|
||||
void window.ipc.invoke(
|
||||
'browser:displayMediaResponse',
|
||||
choice
|
||||
? { requestId, sourceId: choice.sourceId, audio: choice.audio }
|
||||
: { requestId },
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const activeDisplayMediaRequest = displayMediaQueue[0] ?? null
|
||||
|
||||
const setViewVisible = useCallback((visible: boolean) => {
|
||||
if (viewVisibleRef.current === visible) return
|
||||
viewVisibleRef.current = visible
|
||||
|
|
@ -709,11 +405,6 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
autoCapitalize="off"
|
||||
/>
|
||||
</form>
|
||||
<browser-action-list
|
||||
partition="persist:rowboat-browser"
|
||||
alignment="bottom right"
|
||||
className="ml-1 flex shrink-0 items-center"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
|
@ -729,28 +420,6 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!activeAuthRequest && activeDisplayMediaRequest && (
|
||||
<BrowserScreenSharePickerDialog
|
||||
key={activeDisplayMediaRequest.requestId}
|
||||
request={activeDisplayMediaRequest}
|
||||
onSubmit={(sourceId, audio) =>
|
||||
respondToDisplayMedia(activeDisplayMediaRequest.requestId, { sourceId, audio })
|
||||
}
|
||||
onCancel={() => respondToDisplayMedia(activeDisplayMediaRequest.requestId, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Coffee } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* Titlebar indicator shown while Caffeinate (keep-system-awake) is on.
|
||||
* Always mounted and toggled invisible when off — a freshly-mounted no-drag
|
||||
* button inside the drag-region header has its first click swallowed by the
|
||||
* window drag (see the trailing layout control in App.tsx).
|
||||
*/
|
||||
export function CaffeinateIndicator() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.ipc
|
||||
.invoke("power:getCaffeinate", null)
|
||||
.then((res) => {
|
||||
if (!cancelled) setEnabled(res.enabled)
|
||||
})
|
||||
.catch(() => {})
|
||||
const unsubscribe = window.ipc.on("power:caffeinateChanged", ({ enabled }) => {
|
||||
setEnabled(enabled)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Render nothing while off — an invisible placeholder would leave a
|
||||
// permanent 32px hole between the header controls.
|
||||
if (!enabled) return null
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke("power:setCaffeinate", { enabled: false }).catch(() => {
|
||||
toast.error("Failed to turn off Caffeinate")
|
||||
})
|
||||
}}
|
||||
aria-label="Caffeinate is on — click to turn off"
|
||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-amber-500 transition-colors self-center shrink-0 hover:bg-accent hover:text-amber-600"
|
||||
>
|
||||
<Coffee className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Caffeinate is on — your Mac won't sleep. Click to turn off.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { ArrowRight, BookOpen, Mail, Zap } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
/** 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. */
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
|
||||
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
|
||||
{ icon: Zap, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
|
||||
{ icon: BookOpen, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Empty-state body for the chat surface: greeting and starter action cards.
|
||||
* Shown in both the side-pane copilot and full-screen chat; the side pane
|
||||
* (`wide` unset) uses slightly smaller type to fit the narrow column.
|
||||
*/
|
||||
export function ChatEmptyState({
|
||||
onPickPrompt,
|
||||
wide = false,
|
||||
}: ChatEmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('mx-auto flex w-full flex-col gap-5 py-6', wide ? 'max-w-4xl px-4' : 'max-w-md px-2')}>
|
||||
<div>
|
||||
<div className={cn('font-semibold tracking-tight', wide ? 'text-2xl' : 'text-lg')}>
|
||||
What are we working on?
|
||||
</div>
|
||||
<div className={cn('mt-1 text-muted-foreground', wide ? 'text-[15px]' : 'text-[13px]')}>
|
||||
Ask anything, or start with a suggestion.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
{SUGGESTED_ACTIONS.map((action, i) => (
|
||||
<button
|
||||
key={action.title}
|
||||
type="button"
|
||||
onClick={() => onPickPrompt(action.prompt)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-1.5 text-left transition-colors hover:bg-accent/50',
|
||||
wide ? 'px-3.5 py-3' : 'px-3 py-2.5',
|
||||
i > 0 && 'border-t border-border/60',
|
||||
)}
|
||||
>
|
||||
<action.icon className={cn('mr-2 shrink-0 text-foreground/80', wide ? 'size-4' : 'size-3.5')} strokeWidth={1.75} />
|
||||
<span className={cn('shrink-0 font-medium text-foreground', wide ? 'text-sm' : 'text-[13px]')}>
|
||||
{action.title}
|
||||
</span>
|
||||
<span className={cn('truncate text-muted-foreground', wide ? 'text-[13px]' : 'text-[12px]')}>
|
||||
{action.sub}
|
||||
</span>
|
||||
<ArrowRight className={cn('ml-auto shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground', wide ? 'size-3.5' : 'size-3')} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ToolConnectionsCard compact={!wide} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
import { useCallback } from 'react'
|
||||
import { ArrowUpRight, Bug, ChevronDown, MessageSquare, MoreHorizontal, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { TokenUsageMenu } from '@/components/token-usage-menu'
|
||||
import type { TokenUsage } from '@/lib/chat-conversation'
|
||||
import { hasTokenUsage } from '@/lib/token-usage'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
export interface ChatHeaderRecentRun {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
activeTitle: string
|
||||
onNewChatTab: () => void
|
||||
recentRuns?: ChatHeaderRecentRun[]
|
||||
activeRunId?: string | null
|
||||
sessionUsage?: TokenUsage
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Header controls for the copilot/chat surface: the active-chat title with a
|
||||
* recent-chats history dropdown, plus the new-chat button. Rendered identically
|
||||
* whether the chat lives in the side pane (ChatSidebar) or full screen (App
|
||||
* content header). There is a single chat conversation at a time — switching
|
||||
* between chats happens through the history dropdown.
|
||||
*/
|
||||
export function ChatHeader({
|
||||
activeTitle,
|
||||
onNewChatTab,
|
||||
recentRuns = [],
|
||||
activeRunId,
|
||||
sessionUsage,
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
}: ChatHeaderProps) {
|
||||
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
|
||||
const showUsage = hasTokenUsage(sessionUsage)
|
||||
|
||||
const handleDownloadChatLog = useCallback(async () => {
|
||||
if (!activeRunId) {
|
||||
toast.error('No chat log available yet')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 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) {
|
||||
toast.error(result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Download chat log failed:', err)
|
||||
toast.error('Failed to download chat log')
|
||||
}
|
||||
}, [activeRunId])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasHistory ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 text-sm font-medium text-foreground outline-none hover:bg-accent/60"
|
||||
aria-label="Chat history"
|
||||
>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{activeTitle}</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{recentRuns.length > 0 && (
|
||||
<DropdownMenuLabel className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Recent
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{recentRuns.slice(0, 6).map((run) => (
|
||||
<DropdownMenuItem
|
||||
key={run.id}
|
||||
onClick={() => onSelectRun?.(run.id)}
|
||||
className={cn('gap-2', activeRunId === run.id && 'bg-accent')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{run.title || '(Untitled chat)'}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(run.createdAt)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{onOpenChatHistory && (
|
||||
<>
|
||||
{recentRuns.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onOpenChatHistory} className="gap-2 text-primary">
|
||||
<ArrowUpRight className="size-4" />
|
||||
View all chats
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 text-sm font-medium text-foreground">
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{activeTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
{showUsage && (
|
||||
<TokenUsageMenu
|
||||
usage={sessionUsage}
|
||||
scope="session"
|
||||
className="titlebar-no-drag my-1 shrink-0"
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNewChatTab}
|
||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="New chat"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Chat options"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Chat options</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={!activeRunId}
|
||||
onSelect={() => {
|
||||
void handleDownloadChatLog()
|
||||
}}
|
||||
>
|
||||
<Bug className="size-4" />
|
||||
Download chat log
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ExternalLink, MoreVertical, Pencil, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
type Run = {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
agentId: string
|
||||
}
|
||||
|
||||
type ChatHistoryViewProps = {
|
||||
runs: Run[]
|
||||
currentRunId?: string | null
|
||||
processingRunIds?: Set<string>
|
||||
onSelectRun: (runId: string) => void
|
||||
onOpenInNewTab?: (runId: string) => void
|
||||
onRenameRun?: (runId: string, title: string) => void
|
||||
onDeleteRun: (runId: string) => Promise<void> | void
|
||||
onNewChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
export function ChatHistoryView({
|
||||
runs,
|
||||
currentRunId,
|
||||
processingRunIds,
|
||||
onSelectRun,
|
||||
onOpenInNewTab,
|
||||
onRenameRun,
|
||||
onDeleteRun,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
}: ChatHistoryViewProps) {
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
|
||||
const sortedRuns = useMemo(() => {
|
||||
return [...runs].sort((a, b) => {
|
||||
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])
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDeleteId) return
|
||||
const id = pendingDeleteId
|
||||
setPendingDeleteId(null)
|
||||
await onDeleteRun(id)
|
||||
}, [pendingDeleteId, onDeleteRun])
|
||||
|
||||
const startRename = useCallback((run: Run) => {
|
||||
setRenameDraft(run.title || '')
|
||||
setRenamingId(run.id)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback((runId: string) => {
|
||||
const title = renameDraft.trim()
|
||||
const current = runs.find((r) => r.id === runId)
|
||||
setRenamingId(null)
|
||||
if (!title || title === (current?.title ?? '')) return
|
||||
onRenameRun?.(runId, title)
|
||||
}, [renameDraft, runs, onRenameRun])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Chat history</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
)}
|
||||
{onNewChat && (
|
||||
<Button size="sm" onClick={onNewChat}>
|
||||
<SquarePen className="size-4" />
|
||||
New chat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
|
||||
{sortedRuns.length === 0
|
||||
? 'Every conversation you have shows up here.'
|
||||
: `${sortedRuns.length} ${sortedRuns.length === 1 ? 'conversation' : 'conversations'}, newest first.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1120px] px-[30px] pb-12">
|
||||
{sortedRuns.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
No chats yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||
<div className="flex items-center border-b border-border/60 bg-muted/30 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="min-w-0 flex-1">Title</div>
|
||||
<div className="w-28 shrink-0 text-right">Last modified</div>
|
||||
<div className="w-7 shrink-0" />
|
||||
</div>
|
||||
|
||||
{sortedRuns.map((run) => {
|
||||
const isActive = currentRunId === run.id
|
||||
const isProcessing = processingRunIds?.has(run.id)
|
||||
return (
|
||||
<ContextMenu key={run.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative border-b border-border/50 transition-colors last:border-b-0 hover:bg-muted/20',
|
||||
isActive && 'bg-muted/30',
|
||||
)}
|
||||
>
|
||||
{renamingId === run.id ? (
|
||||
<div className="flex items-center px-4 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename(run.id)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setRenamingId(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitRename(run.id)}
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (e.metaKey && onOpenInNewTab) {
|
||||
onOpenInNewTab(run.id)
|
||||
} else {
|
||||
onSelectRun(run.id)
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center px-4 py-2.5 text-left text-sm"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
||||
{run.title || '(Untitled chat)'}
|
||||
</span>
|
||||
<span className="w-28 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatRelativeTime(run.modifiedAt)}
|
||||
</span>
|
||||
<span className="w-7 shrink-0" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Chat options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{onOpenInNewTab && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onOpenInNewTab(run.id)}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Open in new tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{onRenameRun && (
|
||||
<DropdownMenuItem onClick={() => startRename(run)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isProcessing && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setPendingDeleteId(run.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{onOpenInNewTab && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{onRenameRun && (
|
||||
<ContextMenuItem onClick={() => startRename(run)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isProcessing && (
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setPendingDeleteId(run.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => { if (!open) setPendingDeleteId(null) }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete chat</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this chat?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleConfirmDelete()}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -91,28 +91,11 @@ interface ChatMessageAttachmentsProps {
|
|||
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||
if (attachments.length === 0) return null
|
||||
|
||||
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)
|
||||
)
|
||||
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
|
||||
const fileAttachments = attachments.filter((attachment) => !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) => (
|
||||
|
|
|
|||
|
|
@ -1,66 +1,57 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Pin } from 'lucide-react'
|
||||
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChatHeader } from '@/components/chat-header'
|
||||
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageCopyButton,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import { TurnActivityIndicator } from '@/components/turn-activity-indicator'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
|
||||
import { TerminalOutput } from '@/components/terminal-output'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel, type ReasoningEffortLevel } from '@/components/chat-input-with-mentions'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import type { ChatPaneSize } from '@/contexts/theme-context'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type PermissionResponse,
|
||||
type TokenUsage,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
getToolErrorText,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
isTurnUsageMessage,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
REASONING_EFFORT_LABELS,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { matchBillingError } from '@/lib/billing-error'
|
||||
import { TokenUsageMenu } from '@/components/token-usage-menu'
|
||||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
|
|
@ -94,6 +85,60 @@ function AutoScrollPre({ className, children }: { className?: string; children:
|
|||
)
|
||||
}
|
||||
|
||||
/* ─── Billing error helpers ─── */
|
||||
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: 'You\'ve run out of credits',
|
||||
subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
pattern: /subscription not active/i,
|
||||
title: 'Your subscription is inactive',
|
||||
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||
cta: 'Reactivate',
|
||||
},
|
||||
] as const
|
||||
|
||||
function matchBillingError(message: string) {
|
||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||
}
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
appUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
function BillingErrorCTA({ label }: { label: string }) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!appUrl) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 360
|
||||
const MAX_WIDTH = 1600
|
||||
const MIN_MAIN_PANE_WIDTH = 420
|
||||
|
|
@ -125,28 +170,22 @@ interface ChatSidebarProps {
|
|||
defaultWidth?: number
|
||||
isOpen?: boolean
|
||||
isMaximized?: boolean
|
||||
placement?: 'middle' | 'right'
|
||||
paneSize?: ChatPaneSize
|
||||
className?: string
|
||||
chatTabs: ChatTab[]
|
||||
activeChatTabId: string
|
||||
getChatTabTitle: (tab: ChatTab) => string
|
||||
isChatTabProcessing: (tab: ChatTab) => boolean
|
||||
onSwitchChatTab: (tabId: string) => void
|
||||
onCloseChatTab: (tabId: string) => void
|
||||
onNewChatTab: () => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
onOpenFullScreen?: () => void
|
||||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
sessionUsage?: TokenUsage
|
||||
chatTabStates?: Record<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
isProcessing: boolean
|
||||
isReasoning?: boolean
|
||||
isWaitingOnHuman?: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
knowledgeFiles?: string[]
|
||||
recentFiles?: string[]
|
||||
visibleFiles?: string[]
|
||||
|
|
@ -156,21 +195,10 @@ interface ChatSidebarProps {
|
|||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
onReasoningEffortChangeForTab?: (tabId: string, effort: ReasoningEffortLevel | null) => void
|
||||
workDirByTab?: Record<string, string | null>
|
||||
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
|
||||
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
|
||||
/**
|
||||
* Set while a Rowboat-mode code session owns this pane: the chat is pinned to
|
||||
* the session, so the chat switcher / new-chat / history affordances hide.
|
||||
*/
|
||||
pinnedToCodeSession?: { title: string } | null
|
||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse) => void
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||
|
|
@ -180,16 +208,16 @@ interface ChatSidebarProps {
|
|||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void | Promise<void>
|
||||
onSubmitRecording?: () => void
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
inCall?: boolean
|
||||
onStartCall?: (preset: CallPreset) => void
|
||||
onEndCall?: () => void
|
||||
callAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onComposioConnected?: (toolkitSlug: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -197,25 +225,19 @@ export function ChatSidebar({
|
|||
defaultWidth = DEFAULT_WIDTH,
|
||||
isOpen = true,
|
||||
isMaximized = false,
|
||||
placement = 'right',
|
||||
paneSize = 'chat-smaller',
|
||||
className,
|
||||
chatTabs,
|
||||
activeChatTabId,
|
||||
getChatTabTitle,
|
||||
isChatTabProcessing,
|
||||
onSwitchChatTab,
|
||||
onCloseChatTab,
|
||||
onNewChatTab,
|
||||
recentRuns = [],
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
onOpenFullScreen,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
sessionUsage = {},
|
||||
chatTabStates = {},
|
||||
viewportAnchors = {},
|
||||
isProcessing,
|
||||
isReasoning = false,
|
||||
isWaitingOnHuman = false,
|
||||
isStopping,
|
||||
onStop,
|
||||
onSubmit,
|
||||
|
|
@ -228,15 +250,9 @@ export function ChatSidebar({
|
|||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
onReasoningEffortChangeForTab,
|
||||
workDirByTab = {},
|
||||
codeSessionLocks = {},
|
||||
pinnedToCodeSession = null,
|
||||
onWorkDirChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
autoPermissionDecisions = new Map(),
|
||||
onPermissionResponse,
|
||||
onAskHumanResponse,
|
||||
isToolOpenForTab,
|
||||
|
|
@ -247,15 +263,15 @@ export function ChatSidebar({
|
|||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
audioLevelsRef,
|
||||
onStartRecording,
|
||||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
inCall,
|
||||
onStartCall,
|
||||
onEndCall,
|
||||
callAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
|
|
@ -269,8 +285,6 @@ export function ChatSidebar({
|
|||
const startWidthRef = useRef(0)
|
||||
const prevIsMaximizedRef = useRef(isMaximized)
|
||||
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
||||
const isMiddlePlacement = placement === 'middle'
|
||||
const isResizable = paneSize === 'chat-smaller'
|
||||
|
||||
const getMaxAllowedWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') return MAX_WIDTH
|
||||
|
|
@ -331,9 +345,7 @@ export function ChatSidebar({
|
|||
setIsResizing(true)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const delta = isMiddlePlacement
|
||||
? event.clientX - startXRef.current
|
||||
: startXRef.current - event.clientX
|
||||
const delta = startXRef.current - event.clientX
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||
}
|
||||
|
|
@ -346,37 +358,31 @@ export function ChatSidebar({
|
|||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width, getMaxAllowedWidth, isMiddlePlacement])
|
||||
}, [width, getMaxAllowedWidth])
|
||||
|
||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: runId ?? null,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
sessionUsage,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
}), [
|
||||
runId,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
sessionUsage,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
])
|
||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||
if (tabId === activeChatTabId) return activeTabState
|
||||
return chatTabStates[tabId] ?? emptyTabState
|
||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||
const renderConversationItem = (
|
||||
item: ConversationItem,
|
||||
tabId: string,
|
||||
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||
) => {
|
||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
||||
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
|
|
@ -386,7 +392,6 @@ export function ChatSidebar({
|
|||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<div className="flex flex-col items-end">
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
|
|
@ -395,8 +400,6 @@ export function ChatSidebar({
|
|||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<MessageCopyButton text={item.content} className="mt-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -404,7 +407,6 @@ export function ChatSidebar({
|
|||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<div className="flex flex-col items-end">
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
|
|
@ -425,8 +427,6 @@ export function ChatSidebar({
|
|||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<MessageCopyButton text={message} className="mt-0.5" />
|
||||
</div>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
|
@ -467,7 +467,7 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = getToolErrorText(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
return (
|
||||
|
|
@ -475,7 +475,6 @@ export function ChatSidebar({
|
|||
key={item.id}
|
||||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||
autoPermissionDetail={options?.autoPermissionDetail}
|
||||
>
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
|
|
@ -491,27 +490,20 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
|
||||
if (isTurnUsageMessage(item)) {
|
||||
return (
|
||||
<div key={item.id} className="-mt-6 -ml-1 flex items-center justify-start gap-1" data-message-id={item.id}>
|
||||
<TokenUsageMenu
|
||||
usage={item.usage}
|
||||
scope="turn"
|
||||
modelCallCount={item.modelCallCount}
|
||||
align="start"
|
||||
/>
|
||||
{item.reasoningEffort && (
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isErrorMessage(item)) {
|
||||
if (matchBillingError(item.message)) {
|
||||
return null
|
||||
const billingError = matchBillingError(item.message)
|
||||
if (billingError) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
|
||||
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
|
||||
<BillingErrorCTA label={billingError.cta} />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
|
|
@ -534,11 +526,8 @@ export function ChatSidebar({
|
|||
// not add extra width to the right and overflow the app viewport.
|
||||
return { width: 0, flex: '1 1 auto' }
|
||||
}
|
||||
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
|
||||
return { width: 0, flex: '1 1 0' }
|
||||
}
|
||||
return { width, flex: '0 0 auto' }
|
||||
}, [isOpen, isMaximized, paneSize, width])
|
||||
}, [isOpen, isMaximized, width])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -547,19 +536,16 @@ export function ChatSidebar({
|
|||
onMouseDownCapture={onActivate}
|
||||
onFocusCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col overflow-hidden bg-background',
|
||||
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
|
||||
className
|
||||
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{!isMaximized && isResizable && (
|
||||
{!isMaximized && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
|
||||
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
|
||||
'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'
|
||||
|
|
@ -577,35 +563,28 @@ export function ChatSidebar({
|
|||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
{pinnedToCodeSession ? (
|
||||
<TabBar
|
||||
tabs={chatTabs}
|
||||
activeTabId={activeChatTabId}
|
||||
getTabTitle={getChatTabTitle}
|
||||
getTabId={(tab) => tab.id}
|
||||
isProcessing={isChatTabProcessing}
|
||||
onSwitchTab={onSwitchChatTab}
|
||||
onCloseTab={onCloseChatTab}
|
||||
/>
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNewChatTab}
|
||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
This chat is pinned to the coding session — leave the Code view to switch chats.
|
||||
</TooltipContent>
|
||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
sessionUsage={activeTabState.sessionUsage}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
/>
|
||||
)}
|
||||
{onOpenFullScreen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -614,14 +593,14 @@ export function ChatSidebar({
|
|||
size="icon"
|
||||
onClick={onOpenFullScreen}
|
||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
|
||||
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
>
|
||||
{isMaximized
|
||||
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
|
||||
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
|
||||
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</header>
|
||||
|
|
@ -650,21 +629,16 @@ export function ChatSidebar({
|
|||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={cn(
|
||||
'mx-auto w-full max-w-4xl px-3',
|
||||
tabHasConversation ? 'pb-28' : 'pb-0',
|
||||
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
|
||||
)}>
|
||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
onPickPrompt={setLocalPresetMessage}
|
||||
/>
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{groupConversationItems(
|
||||
tabState.conversation,
|
||||
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||
(id) => !!tabState.allPermissionRequests.get(id)
|
||||
).map((item) => {
|
||||
if (isToolGroup(item)) {
|
||||
return (
|
||||
|
|
@ -676,42 +650,23 @@ export function ChatSidebar({
|
|||
/>
|
||||
)
|
||||
}
|
||||
const autoDecision = isToolCall(item)
|
||||
? tabState.autoPermissionDecisions.get(item.id)
|
||||
: undefined
|
||||
const rendered = renderConversationItem(
|
||||
item,
|
||||
tab.id,
|
||||
autoDecision?.decision === 'allow'
|
||||
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
|
||||
: undefined,
|
||||
)
|
||||
if (isToolCall(item)) {
|
||||
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{deniedAutoDecision && (
|
||||
<AutoPermissionDecision
|
||||
toolCall={deniedAutoDecision.toolCall}
|
||||
permission={deniedAutoDecision.permission}
|
||||
decision={deniedAutoDecision.decision}
|
||||
reason={deniedAutoDecision.reason}
|
||||
/>
|
||||
)}
|
||||
{permRequest && onPermissionResponse && (
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
permission={permRequest.permission}
|
||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isActive && isProcessing && !isWaitingOnHuman}
|
||||
isProcessing={isActive && isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
)}
|
||||
{rendered}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
@ -724,7 +679,7 @@ export function ChatSidebar({
|
|||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing && !isWaitingOnHuman}
|
||||
isProcessing={isActive && isProcessing}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -736,10 +691,10 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && (
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<TurnActivityIndicator isReasoning={isReasoning} />
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
|
@ -756,6 +711,9 @@ export function ChatSidebar({
|
|||
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
||||
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="mx-auto w-full max-w-4xl px-3">
|
||||
{!hasConversation && (
|
||||
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
||||
)}
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getTabState(tab.id)
|
||||
|
|
@ -784,22 +742,18 @@ export function ChatSidebar({
|
|||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
onReasoningEffortChange={onReasoningEffortChangeForTab ? (effort) => onReasoningEffortChangeForTab(tab.id, effort) : 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}
|
||||
inCall={inCall}
|
||||
onStartCall={isActive ? onStartCall : undefined}
|
||||
onEndCall={isActive ? onEndCall : undefined}
|
||||
callAvailable={callAvailable}
|
||||
ttsAvailable={isActive && ttsAvailable}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMode={ttsMode}
|
||||
onToggleTts={isActive ? onToggleTts : undefined}
|
||||
onTtsModeChange={isActive ? onTtsModeChange : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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'
|
||||
}
|
||||
|
|
@ -1,496 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
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' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,511 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue