mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
commit
50beb9f33e
353 changed files with 56361 additions and 4785 deletions
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
BIN
apps/x/.pnpm-store/v11/index.db
Normal file
BIN
apps/x/.pnpm-store/v11/index.db
Normal file
Binary file not shown.
|
|
@ -24,7 +24,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run
|
|||
|
||||
| Property | Type | Notes |
|
||||
|---|---|---|
|
||||
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
|
||||
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` |
|
||||
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
|
||||
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
|
||||
| `model` | string | e.g. `claude-sonnet-4-6` |
|
||||
|
|
@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase:
|
|||
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
|
||||
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
|
||||
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
|
||||
| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) |
|
||||
|
||||
##### `live_note_agent` sub-use-case shape
|
||||
|
||||
|
|
@ -91,6 +92,8 @@ All in `apps/renderer/src/lib/analytics.ts`:
|
|||
- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }`
|
||||
- `oauth_connected` / `oauth_disconnected` — `{ provider }`
|
||||
- `voice_input_started` — no properties
|
||||
- `call_started` — `{ preset: 'voice' | 'video' | 'share' | 'practice' }` — a hands-free call began (see `apps/x/VIDEO_MODE.md`)
|
||||
- `call_turn_latency` — `{ endpoint_to_submit_ms, submit_to_speak_ms, speak_to_audio_ms, total_ms }` — voice-to-voice latency breakdown for one call turn (utterance accepted → submitted → first TTS speak → audio playing)
|
||||
- `search_executed` — `{ types: string[] }`
|
||||
- `note_exported` — `{ format }`
|
||||
|
||||
|
|
|
|||
271
apps/x/CODE_MODE_ENGINES_PLAN.md
Normal file
271
apps/x/CODE_MODE_ENGINES_PLAN.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# Code Mode — Managed Engine Provisioning Plan
|
||||
|
||||
Branch: `feat/code-mode-managed-engines` (off `dev` @ `8ce24ebb`)
|
||||
|
||||
## 1. Problem & Goal
|
||||
|
||||
Code mode runs two coding agents — **Claude Code** and **Codex** — by spawning their
|
||||
ACP adapters, which in turn spawn a heavy **native engine binary** (~205 MB claude,
|
||||
~194 MB codex). We need code mode to work in **packaged releases with ~99% reliability
|
||||
for both agents**, without shipping a ~400 MB installer.
|
||||
|
||||
### Current state (HEAD = revert of #614)
|
||||
- Packaged builds **do not stage** the ACP adapters, and `forge.config.cjs`
|
||||
`ignore: /^\/node_modules\//` strips them. At runtime `agents.ts` resolves the
|
||||
adapter via `require.resolve(...)` then spawns it — which **throws
|
||||
`Cannot find module '@agentclientprotocol/...'`**.
|
||||
- **Net: packaged code mode is broken in every release.** It only works in `dev`
|
||||
because pnpm symlinks exist. There is no 400 MB bloat today — but no function either.
|
||||
|
||||
### Why the two prior approaches are insufficient
|
||||
- **Bundle engines (A):** +~400 MB per installer (one claude + one codex native binary
|
||||
per OS/arch). Works offline, but installer is huge.
|
||||
- **Drive from user's local install (B)** — what #614 settled on and was reverted:
|
||||
requires the user to have **both** CLIs installed **and** logged in, correct version,
|
||||
on the right PATH. Depends on the user's machine → **structurally cannot hit 99%**
|
||||
(version skew, GUI-launch PATH stripping, missing installs). This is why #614 was
|
||||
reverted.
|
||||
|
||||
## 2. Chosen Architecture — Managed Engine Provisioning (validated against Conductor)
|
||||
|
||||
Split the problem into **engine** vs **auth**, treat them differently — exactly what
|
||||
Conductor (conductor.build) does:
|
||||
|
||||
1. **Engine = owned by the app.** We provision **version-pinned** engine binaries into
|
||||
**app-support** (`~/.rowboat/engines/<agent>/<version>/`), download-on-demand on first
|
||||
use, sha256-verified, symlink/path-pinned. Not the user's global npm, not their PATH.
|
||||
→ no version skew, no PATH quirks → this is what delivers the 99%.
|
||||
2. **Auth = reused from the user.** The engines read existing credentials
|
||||
(`~/.claude` API key / Pro / Max, `~/.codex` auth.json). No second login. `status.ts`
|
||||
already inspects these.
|
||||
|
||||
### Empirical proof from Conductor on this machine
|
||||
- DMG is **123 MB** — far smaller than the ~400 MB of engines → engines are **not** in
|
||||
the installer; they're downloaded after install.
|
||||
- Layout observed at `~/Library/Application Support/com.conductor.app/`:
|
||||
```
|
||||
agent-binaries/claude/2.1.170/claude (222 MB, single Mach-O arm64)
|
||||
agent-binaries/codex/0.138.0/codex (205 MB, single Mach-O arm64)
|
||||
bin/claude -> agent-binaries/claude/2.1.170/claude (symlink to active version)
|
||||
bin/codex -> agent-binaries/codex/0.138.0/codex
|
||||
agent-binaries/.meta/claude-2.1.170.json = {sha256, size, downloaded_at_unix_ms, ...}
|
||||
```
|
||||
- So: versioned dirs + stable symlink + sha256 `.meta` ledger + download. We mirror this.
|
||||
|
||||
## 3. Concrete facts that make this clean (verified)
|
||||
|
||||
### Adapters honor an external engine via env var (no code change in adapters needed)
|
||||
- **Claude** — `@agentclientprotocol/claude-agent-acp@0.39.0`,
|
||||
`dist/acp-agent.js:39`: `if (process.env.CLAUDE_CODE_EXECUTABLE) return it;`
|
||||
and line 1552 `pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? ...`.
|
||||
If unset and no bundled native dep → it throws "set CLAUDE_CODE_EXECUTABLE".
|
||||
- **Codex** — `@agentclientprotocol/codex-acp@0.0.44`,
|
||||
`dist/index.js:20900`: `const codexPath = process.env["CODEX_PATH"] ?? "codex";`
|
||||
→ `spawn(codexPath, ["app-server"])`.
|
||||
- **Implication:** provisioning + setting these two env vars is the *entire* engine story.
|
||||
|
||||
### Our engine packages are already single self-contained binaries
|
||||
- `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156` → contains one `claude`
|
||||
executable (+ LICENSE/README).
|
||||
- `@openai/codex@0.128.0-darwin-arm64` → `vendor/<target>/codex/codex` native binary
|
||||
**plus a bundled `rg` (ripgrep) at `vendor/<target>/path/rg`** — see Risk R1.
|
||||
|
||||
### Pinned versions + platform package names (read from the installed adapter trees)
|
||||
- **Claude:** adapter `claude-agent-acp@0.39.0` → engine `@anthropic-ai/claude-agent-sdk@0.3.156`.
|
||||
Platform optional deps `@anthropic-ai/claude-agent-sdk-<platform>@0.3.156`:
|
||||
`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linux-x64-musl`,
|
||||
`linux-arm64-musl`, `win32-x64`, `win32-arm64`.
|
||||
- **Codex:** adapter `codex-acp@0.0.44` → `@openai/codex@^0.128.0` (pnpm-**patched**, see R2).
|
||||
Platform deps aliased `@openai/codex-<platform>` → `npm:@openai/codex@0.128.0-<platform>`:
|
||||
`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`.
|
||||
|
||||
## 4. Distribution source — npm platform packages, adapter-pinned (DECIDED)
|
||||
|
||||
We fetch the **per-platform engine packages from the npm registry at the exact versions
|
||||
our ACP adapters depend on**, extract the native binary, and provision it into
|
||||
`~/.rowboat/engines/...`. No self-host, no curl installer, no fallback.
|
||||
|
||||
- **Tarball URL:** `https://registry.npmjs.org/<pkg>/-/<file>-<version>.tgz`
|
||||
- claude: `@anthropic-ai/claude-agent-sdk-<platform>@0.3.156` → extract the `claude` binary.
|
||||
- codex: `@openai/codex@0.128.0-<platform>` → extract `vendor/<target>/codex/codex`
|
||||
**and keep `vendor/<target>/path/rg`** (see R1).
|
||||
- **Why adapter-pinned npm (not curl installer, not release bucket, not self-host):**
|
||||
- The binary is the **exact version our adapter was built/tested against** → ACP
|
||||
handshake guaranteed → the key to ~99% reliability.
|
||||
- npm registry is highly available, immutable per-version, and the packument provides
|
||||
`dist.integrity` (sha512) + `dist.shasum` (sha1) → integrity verification with **zero
|
||||
infra**.
|
||||
- The official `curl | bash` installer is for end-users: it installs globally to
|
||||
`~/.local/bin` and **auto-updates in the background** — exactly what we must avoid. We
|
||||
want an isolated, pinned, app-managed copy that never moves under the adapter.
|
||||
(Conductor likewise fetches the raw binary rather than running the installer.)
|
||||
- **Versions are read from our lockfile at build time** and embedded in
|
||||
`engine-manifest.json`, so the manifest can never drift from the adapters we ship.
|
||||
|
||||
### Reference: how this relates to Conductor / official installers
|
||||
- Conductor self-hosts the same kind of native binaries on `storage.conductor.build`
|
||||
(raw/`.gz`/`.zst` + `{url,gzipUrl,zstdUrl,sha256}` manifest), pairing the adapters with
|
||||
recent **standalone CLI** versions (claude 2.1.x, codex 0.138). That proves recent
|
||||
versions work — but we deliberately pin to the **adapter's own** engine version for
|
||||
determinism.
|
||||
- Official Claude releases also publish a GPG-signed `manifest.json` (SHA256/platform) at
|
||||
`downloads.claude.ai/claude-code-releases/<ver>/`. We don't use it now (npm is simpler
|
||||
and adapter-matched), but it's the upgrade path if we ever want the latest CLI line.
|
||||
- If we later want control/availability/compression, we can mirror these npm tarballs to
|
||||
our own bucket — **runtime stays identical, only manifest URLs change.**
|
||||
|
||||
### Locked decisions
|
||||
- **Source: npm platform packages, adapter-pinned versions (user).**
|
||||
- **No self-host (user).**
|
||||
- **Always provision; no fallback** to a user's pre-installed `claude`/`codex` (user).
|
||||
Reverted path B is fully retired.
|
||||
- **Codex pnpm patch — IRRELEVANT (user):** it targets the JS launcher; we point
|
||||
`CODEX_PATH` at the native binary, so it does not apply. (Risk R2 removed.)
|
||||
|
||||
## 5. Design
|
||||
|
||||
### 5.1 Build-time: generate an engine manifest + stage adapter JS
|
||||
|
||||
**(a) Engine manifest (`engine-manifest.json`, embedded in the app).**
|
||||
A build script reads the installed adapter dependency trees and emits, per agent:
|
||||
```jsonc
|
||||
{
|
||||
"claude": {
|
||||
"version": "0.3.156",
|
||||
"platforms": {
|
||||
"darwin-arm64": { "pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
|
||||
"tarball": "https://registry.npmjs.org/.../-/...-0.3.156.tgz",
|
||||
"integrity": "sha512-...",
|
||||
"binRelPath": "claude" },
|
||||
"...": {}
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"version": "0.128.0",
|
||||
"platforms": {
|
||||
"darwin-arm64": { "pkg": "@openai/codex-darwin-arm64",
|
||||
"tarball": "https://registry.npmjs.org/...",
|
||||
"integrity": "sha512-...",
|
||||
"binRelPath": "vendor/aarch64-apple-darwin/codex/codex",
|
||||
"extraPaths": ["vendor/aarch64-apple-darwin/path/rg"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Versions + tarball URLs + integrity are pulled from the lockfile / npm packument so the
|
||||
manifest is always in sync with the adapters we ship. (Generating it at build time
|
||||
sidesteps hardcoding fragile package names.)
|
||||
- `binRelPath` / `extraPaths` capture where the executable (and codex's `rg`) live inside
|
||||
each tarball.
|
||||
|
||||
**(b) Stage the ACP adapter JS into the package (the part of #614 we KEEP).**
|
||||
The adapters themselves (tiny, ~15 MB total incl. their non-native JS deps) must exist on
|
||||
disk in packaged builds so `agents.ts` can resolve + spawn them. In `forge.config.cjs`
|
||||
`generateAssets`, stage the two adapters and their **non-native** production dependency
|
||||
closure into `.package/acp/node_modules` (npm-style nested layout), and **exempt
|
||||
`.package` from the `node_modules` ignore rule**. We **drop** #614's native-engine staging
|
||||
entirely — engines come from provisioning, not the bundle.
|
||||
|
||||
### 5.2 Runtime: engine provisioner (new module in `packages/core`)
|
||||
|
||||
New file: `packages/core/src/code-mode/acp/engine-provisioner.ts`.
|
||||
|
||||
```
|
||||
ensureEngine(agent): Promise<{ executablePath: string }>
|
||||
1. Read manifest entry for (agent, currentPlatform). Error clearly if unsupported.
|
||||
2. dir = ~/.rowboat/engines/<agent>/<version>/
|
||||
3. If dir exists AND .meta/<agent>-<version>.json sha256 matches → return binPath.
|
||||
4. Else acquire a cross-process lock (avoid double download), then:
|
||||
a. Download tarball to a temp file, streaming, with progress events.
|
||||
b. Verify integrity (sha512/sha256 from manifest).
|
||||
c. Extract into a temp dir, then atomic rename into <version>/ (tar gzip).
|
||||
d. chmod +x the binary (and codex's rg) on unix.
|
||||
e. Write .meta/<agent>-<version>.json {sha256, size, downloaded_at_unix_ms}.
|
||||
5. Return absolute path to the engine executable (binRelPath joined to dir).
|
||||
```
|
||||
- **Progress + cancellation** surfaced over IPC for the first-run "Downloading engine…" UI.
|
||||
- **Offline / failure** → typed error with a clear, actionable message (not a hang).
|
||||
- **Resumability / atomicity:** download to temp, verify, then rename; never leave a
|
||||
half-extracted version dir that passes the existence check.
|
||||
|
||||
### 5.3 Wire provisioning into launch
|
||||
|
||||
In `agents.ts` `getAgentLaunchSpec()` (currently sets only `CLAUDE_CODE_EXECUTABLE`):
|
||||
- Make it (or its caller in `client.ts` / `manager.ts`) `await ensureEngine(agent)` first,
|
||||
then set:
|
||||
- claude → `env.CLAUDE_CODE_EXECUTABLE = <provisioned claude>`
|
||||
- codex → `env.CODEX_PATH = <provisioned codex>` (and ensure its `rg` sibling resolves;
|
||||
keep the vendor dir layout intact so codex finds `../path/rg`).
|
||||
- Keep `ELECTRON_RUN_AS_NODE=1` for the adapter spawn (unchanged).
|
||||
- The provisioner replaces both the bundled-engine dependency *and* the reverted
|
||||
"resolve user's local install" path. (Optionally: if a healthy provisioned engine is
|
||||
absent but a compatible local install exists, we *may* fall back to it — but the default
|
||||
and reliable path is the provisioned engine. Decide in review; default = provisioned only.)
|
||||
|
||||
### 5.4 Status & UX (`status.ts` + renderer)
|
||||
- `checkCodeModeAgentStatus()` becomes: engine = provisioned? (instead of "installed on
|
||||
PATH?"); auth = existing credentials present? (unchanged logic).
|
||||
- First-run flow: user picks agent → if engine missing, show "Downloading <agent> engine
|
||||
(~200 MB), one time…" with progress; on completion, proceed. Subsequent uses: instant.
|
||||
- Clear error states: download failed / offline / unsupported platform / auth missing.
|
||||
|
||||
## 6. File-by-file change plan
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `apps/main/forge.config.cjs` | Stage adapter JS closure into `.package/acp/node_modules`; exempt `.package` from node_modules ignore; generate + copy `engine-manifest.json`. (No native engines staged.) |
|
||||
| `apps/main/scripts/gen-engine-manifest.mjs` *(new)* | Build-time: read lockfile/packuments → emit `engine-manifest.json` (versions, tarball URLs, integrity, bin paths). |
|
||||
| `packages/core/src/code-mode/acp/engine-provisioner.ts` *(new)* | `ensureEngine()` — download, verify, extract, lock, progress, `.meta` ledger. |
|
||||
| `packages/core/src/code-mode/acp/agents.ts` | Resolve adapter from staged `.package/acp` first (fallback to node_modules in dev); `await ensureEngine`; set `CLAUDE_CODE_EXECUTABLE`/`CODEX_PATH` to provisioned binaries. |
|
||||
| `packages/core/src/code-mode/acp/client.ts` / `manager.ts` | Await provisioning before spawn; surface provisioning progress/errors; keep startup deadline. |
|
||||
| `packages/core/src/code-mode/status.ts` | Engine status = provisioned (not PATH); keep auth checks. |
|
||||
| `apps/main/src/ipc.ts` + preload + renderer | IPC for provisioning progress + first-run download UI + error states. |
|
||||
| CI (optional) | Smoke: packaged app boots each adapter, provisions a (small fake) engine, sets env var, answers ACP `initialize`; offline → clear error not a hang. |
|
||||
|
||||
## 7. Edge cases & risks
|
||||
|
||||
- **R1 — Codex needs `rg`.** The codex platform package bundles ripgrep at
|
||||
`vendor/<target>/path/rg`. We must extract/keep the vendor layout so codex finds it
|
||||
(don't extract the bare binary). Verify codex `app-server` boots from the provisioned dir.
|
||||
- **R2 — Codex pnpm patch. RESOLVED (irrelevant).** The patch targets the JS launcher; we
|
||||
point `CODEX_PATH` at the native binary, so it does not apply.
|
||||
- **R3 — Platform/arch matrix.** Manifest must cover darwin x64/arm64, linux x64/arm64
|
||||
(+musl for claude), win32 x64/arm64. Windows engine is `claude.exe`/`codex.exe`.
|
||||
- **R4 — Integrity & supply chain.** Always verify the manifest integrity hash before
|
||||
chmod/exec. Treat a hash mismatch as a hard failure.
|
||||
- **R5 — Disk + upgrades.** Versioned dirs accumulate. Add a cleanup that keeps only the
|
||||
active pinned version per agent.
|
||||
- **R6 — First-run network.** Required once per agent; cached forever after. Must be a
|
||||
clear, cancellable UX, never a silent hang (reuse the #614 startup-deadline lesson).
|
||||
- **R7 — code signing / Gatekeeper (macOS).** Downloaded native binaries aren't covered by
|
||||
our app's signature. Verify they run under Gatekeeper (they're already
|
||||
signed/notarized by Anthropic/OpenAI; quarantine attr may need clearing). Conductor runs
|
||||
them fine from app-support — confirm we do too.
|
||||
- **R8 — `engine-manifest` staleness.** Manifest must regenerate whenever the adapter/engine
|
||||
versions change; tie generation into the build so it can't drift.
|
||||
|
||||
## 8. Phasing
|
||||
|
||||
1. **P1 — Packaging fix (unblocks dev→packaged parity):** stage adapter JS into `.package`,
|
||||
resolver checks staged path first. Verify packaged code mode can at least *spawn* the
|
||||
adapter. (Independent of provisioning; the part of #614 worth keeping.)
|
||||
2. **P2 — Provisioner (core):** `ensureEngine()` + manifest + wire env vars. Verify both
|
||||
engines provision + boot from `~/.rowboat/engines` on macOS arm64.
|
||||
3. **P3 — UX + status:** first-run download UI, status panel, error states.
|
||||
4. **P4 — Cross-platform + CI smoke:** matrix manifest, mac/linux/win smoke.
|
||||
5. **P5 — Polish:** version cleanup, cancellation, offline messaging, optional local-install
|
||||
fallback.
|
||||
|
||||
## 9. Decisions & remaining questions
|
||||
|
||||
### Resolved (locked)
|
||||
1. **Source = npm platform packages, adapter-pinned versions** (not self-host, not curl
|
||||
installer, not release bucket).
|
||||
2. **Always provision; no local-install fallback.**
|
||||
3. **Codex pnpm patch irrelevant.**
|
||||
|
||||
### Still open (can decide during implementation)
|
||||
- **Provision timing:** on first code-mode *use* (lazy) vs first app launch (eager,
|
||||
background download). Plan assumes **lazy + cached**.
|
||||
- **Version-dir cleanup:** keep only the active pinned version per agent (R5).
|
||||
- **Verify R1** (codex `rg`) and **R7** (Gatekeeper on downloaded macOS binaries) during P2.
|
||||
307
apps/x/MINI_APPS_PLAN.md
Normal file
307
apps/x/MINI_APPS_PLAN.md
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
# Mini Apps — Implementation Plan
|
||||
|
||||
> Status: **Phase 1 (UI + rendering, hand-coded apps)** in progress.
|
||||
> Branch: `feature/mini-apps`. Working dir: `apps/x`.
|
||||
|
||||
## 1. What we're building (agreed design)
|
||||
|
||||
A **Mini App** is a user-created, personal app that lives inside Rowboat under a
|
||||
**"Mini Apps"** sidebar tab, shown as cards; click a card to open and use it like
|
||||
a standalone app.
|
||||
|
||||
The settled mental model:
|
||||
|
||||
> A Mini App = **custom UI code** (a single self-contained HTML file: React +
|
||||
> Tailwind, no build step, runs in a sandboxed iframe) + an **agent "backend"**
|
||||
> that produces fresh **data** on a trigger + an optional **state store** + a
|
||||
> **scoped bridge** that lets the UI call real **Composio** actions.
|
||||
|
||||
Key decisions (the "why" behind the architecture):
|
||||
|
||||
- **Opens to a finished, populated screen.** The agent runs *before* open (on a
|
||||
trigger); the UI just renders the latest result. Maps onto the existing
|
||||
background-agents engine.
|
||||
- **Frontend/backend split.** Code = frontend, written once. Agent = backend,
|
||||
produces fresh `data.json` each run. The UI is *not* regenerated per run.
|
||||
Cheaper, faster, and the UI can't break on refresh.
|
||||
- **A data contract** ties the three pieces together: at creation the builder
|
||||
produces a matched triple — UI code + data schema + agent instructions.
|
||||
- **Fully custom UI per app** (generated code), not a fixed block kit.
|
||||
- **Real interactivity** via a **scoped bridge** to Composio (each app declares
|
||||
the integrations it may touch; scope drives both enforcement and the auth
|
||||
prompt).
|
||||
- **Built by whatever engine is selected in the chat box** — Code Mode
|
||||
(Claude Code / Codex) if the user has it, else the Copilot.
|
||||
- **Living apps:** refined by chatting; breakage is first-class (app surfaces
|
||||
errors → "fix with copilot").
|
||||
- **State is an optional provided primitive** (per-app persistent store the agent
|
||||
and UI can read/write). Stateless apps ignore it.
|
||||
- **Personal/local only** for now, but each app is a **self-contained folder**
|
||||
so it can become portable later.
|
||||
|
||||
### Framework for the apps
|
||||
|
||||
Single self-contained `index.html` rendered in a sandboxed iframe.
|
||||
|
||||
> **Learning (Phase 1):** remote CDNs do NOT work inside the sandboxed,
|
||||
> opaque-origin `srcdoc` iframe (React/Tailwind/Babel from unpkg/cdn.tailwindcss
|
||||
> failed → blank screen). Apps must be **fully self-contained, no network**.
|
||||
> Phase 1 therefore ships **vanilla HTML + CSS + JS** (no build, no transpile).
|
||||
> The React+Tailwind LLM-friendly target still stands for generation — but via a
|
||||
> **locally-bundled** runtime (esbuild-at-save, libs injected into the HTML),
|
||||
> never remote CDNs. The `window.rowboat` bridge is unchanged either way.
|
||||
|
||||
The UI talks to the host through a `window.rowboat` **bridge** (the product
|
||||
surface *and* the security boundary):
|
||||
|
||||
```
|
||||
rowboat.getData(): Promise<Data> // latest agent output
|
||||
rowboat.onData(cb): void // re-render on refresh
|
||||
rowboat.getState() / setState(patch) // per-app persistent store
|
||||
rowboat.callAction(scope, action, args) // scoped Composio call
|
||||
rowboat.ready() // handshake: "send me data"
|
||||
```
|
||||
|
||||
## 2. Phased roadmap
|
||||
|
||||
1. **Surface + one real app (UI-first — THIS PHASE).** "Mini Apps" sidebar tab,
|
||||
card grid, click to open. One **hardcoded** app (Twitter client) rendered in a
|
||||
sandboxed iframe from a self-contained HTML file, reading static data through a
|
||||
minimal bridge. Proves the *experience* and the *rendering path*. No agent,
|
||||
no storage, no real Composio yet.
|
||||
2. **The scoped bridge.** Real interactive buttons → real Composio actions,
|
||||
enforced against a per-app manifest scope; auth prompt on demand.
|
||||
3. **Generation.** Copilot/Code-Mode generates the UI+schema+agent triple from a
|
||||
chat description. Rides the existing chat mode selector + `code-with-agents`.
|
||||
4. **Living apps + breakage recovery.** Conversational edits; error surfacing.
|
||||
5. **Scale & polish.** Context-overload windowing (e.g. 100 tweets); app
|
||||
notifications (reuse `notify-user`).
|
||||
6. **(V2)** Drag an app into the main workspace (macOS-style).
|
||||
|
||||
## 2b. Phase 2.5 — On-disk apps + `app://miniapp` serving (CURRENT)
|
||||
|
||||
Move built-in apps out of source into `~/.rowboat/apps/<id>/`, served as static
|
||||
assets, with an optional background agent producing `data.json`. Sets up the
|
||||
copilot builder path (it will write the same folder layout). Team-agreed.
|
||||
|
||||
### On-disk layout (one folder per app)
|
||||
```
|
||||
~/.rowboat/apps/<id>/
|
||||
manifest.json # id, title, description, source, scope[], active, lastRun,
|
||||
# entry (default "dist/index.html"), agent (optional bg-task slug)
|
||||
dist/ # static assets served via app://miniapp/<id>/...
|
||||
index.html # the app (self-contained; bridge shim inlined)
|
||||
data.json # latest agent output (read by the host, pushed to the app)
|
||||
```
|
||||
|
||||
### Serving — `app://miniapp/<id>/<path>` (option A)
|
||||
- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host
|
||||
`miniapp`: map `app://miniapp/<id>/<path>` → `~/.rowboat/apps/<id>/dist/<path>`
|
||||
(path-traversal guarded; default `index.html`). `app://` is already registered
|
||||
privileged (standard/secure/fetch/cors) so apps get a **real origin** —
|
||||
remote CDNs/images and `fetch` work, unlike the opaque `srcdoc` origin.
|
||||
- The iframe loads via `src="app://miniapp/<id>/index.html"` (not `srcDoc`).
|
||||
Sandbox becomes `allow-scripts allow-same-origin allow-popups allow-forms
|
||||
allow-modals allow-downloads` — same-origin is its OWN `app://miniapp` origin,
|
||||
still isolated from the renderer (different host).
|
||||
|
||||
### Data path
|
||||
- Built-in/static `data` is seeded to `data.json`. A future background agent
|
||||
(reuse bg-tasks engine, linked via manifest `agent`) overwrites it on schedule.
|
||||
- App keeps using `rowboat.onData`; the **host** now sources that data by reading
|
||||
`~/.rowboat/apps/<id>/data.json` (via IPC) and posting it on `ready`. Composio
|
||||
still flows through the bridge RPC. (GitHub app needs no `data.json` — it pulls
|
||||
live via Composio.)
|
||||
|
||||
### Steps
|
||||
1. **Shared schema** — `packages/shared/src/mini-app.ts`: `MiniAppManifest` zod +
|
||||
type. Export it. New IPC channels in `shared/src/ipc.ts`:
|
||||
- `mini-apps:seed` (req: `{apps:[{manifest, html, data?}]}`) — idempotent.
|
||||
- `mini-apps:list` (res: `{manifests: MiniAppManifest[]}`).
|
||||
- `mini-apps:get-data` (req `{id}`, res `{data: unknown|null}`).
|
||||
2. **Main** — `apps/main/src/mini-apps-handler.ts`: `seedApps` (write
|
||||
manifest/dist/data to `~/.rowboat/apps/<id>/` only if absent), `listApps`
|
||||
(read manifests), `getAppData` (read data.json). Register handlers in
|
||||
`ipc.ts`. Extend `registerAppProtocol` for the `miniapp` host.
|
||||
3. **Renderer** — keep `MiniApp` defs + `buildMiniAppHtml`; `registry.ts` adds
|
||||
`toSeed(app)` (→ `{manifest, html, data}`). `MiniAppsView`: on mount → `seed`
|
||||
→ `list` → render cards from manifests. `MiniAppFrame`: take the manifest,
|
||||
load `src=app://miniapp/<id>/<entry>`, on `ready` fetch `mini-apps:get-data`
|
||||
and post it; RPC scope from `manifest.scope`.
|
||||
4. **Verify**: GitHub app end-to-end from disk (`~/.rowboat/apps/github-radar/`),
|
||||
then the others; confirm connect + live PRs still work.
|
||||
|
||||
### Out of scope here (next: copilot builder, tomorrow)
|
||||
Copilot writing a new app folder + an associated background agent; the agent
|
||||
browsing via embedded browser for social feeds; copilot verifying Composio wiring
|
||||
by actually calling tools before finalizing.
|
||||
|
||||
## 2c. Remaining work (after on-disk move)
|
||||
|
||||
Done so far: surface + runtime (Phase 1), real scoped Composio bridge (Phase 2),
|
||||
apps on disk served via `app://miniapp` (Phase 2.5).
|
||||
|
||||
**Next — Copilot builder (demo target).**
|
||||
- Copilot creates an app folder in `~/.rowboat/apps/<id>/` via the `mini-apps:seed`
|
||||
install primitive (writes `manifest.json` + `dist/index.html`).
|
||||
- Copilot must **verify wiring by actually calling Composio tools** and inspecting
|
||||
the returned data before finalizing — never speculate the shape.
|
||||
- Give generated apps the bridge contract — prefer **serving a canonical shim**
|
||||
from the protocol (e.g. `app://miniapp/__bridge__.js`) over inlining.
|
||||
|
||||
**Background-agent data pipeline (deterministic).**
|
||||
- Reuse the existing bg-tasks engine; link via `manifest.agent`.
|
||||
- The agent does NOT write files. It **returns structured data validated against
|
||||
the app's data schema**; the bg-task **runner (code) atomically writes the
|
||||
app's `data.json`** (temp→rename, keep last-good on failure). Path / atomicity
|
||||
/ validation / fallback all live in code — only the *content* is LLM-driven.
|
||||
- Social feeds (Twitter/LinkedIn/Reddit): the agent browses via the embedded
|
||||
browser, curates, returns data → runner writes `data.json`.
|
||||
|
||||
**Later (roadmap).**
|
||||
- Living apps + breakage recovery (edit by chat; surface errors → fix-with-copilot).
|
||||
- Scale/polish: context-overload windowing; app notifications; design-consistency
|
||||
/ component templates (team-deferred).
|
||||
- V2: drag an app into the main workspace.
|
||||
|
||||
## 2d. Mini App builder skill — spec
|
||||
|
||||
A Copilot skill (`build-mini-app`, in `packages/core/src/application/assistant/
|
||||
skills/`) that turns a chat request into an installed app under `~/.rowboat/
|
||||
apps/<id>/`. Copilot orchestrates; the actual code-writing is delegated by the
|
||||
chat's active engine (the chip), but the on-disk artifact is identical either way.
|
||||
|
||||
**Trigger + intent gate** (like live-note/background-task signal tiers):
|
||||
- **Strong (build directly):** "make/build/create an app · mini app · dashboard
|
||||
for …", "turn this into an app".
|
||||
- **Medium (CONFIRM FIRST):** requests that could be a one-off answer or a
|
||||
recurring app — e.g. "show me my open PRs", "track competitor launches". Ask:
|
||||
"Want this as a Mini App you can reopen, or just a one-time answer?" Build only
|
||||
on yes. (Building installs a folder + maybe a bg agent + an OAuth prompt — too
|
||||
heavy to trigger on a casual question.)
|
||||
- **Anti (do NOT build):** clearly one-off lookups/questions → just answer.
|
||||
|
||||
**Flow:**
|
||||
1. **Scope the app** — derive `id` (slug), `title`, `description`, `source`, the
|
||||
Composio `scope[]`, and whether it's **agent-backed** (scheduled data) or
|
||||
**live** (calls Composio on use).
|
||||
2. **Verify wiring BEFORE building** (mandatory, engine-agnostic) — ensure the
|
||||
toolkits are connected (prompt OAuth if not), then actually call the needed
|
||||
tools (`composio-search-tools` → `composio-execute-tool`), inspect the REAL
|
||||
returned data, and derive the **data schema from actual responses** — never
|
||||
guess the shape.
|
||||
3. **Pick the writer (branch):**
|
||||
- **Code Mode active** → create the folder + a manifest skeleton, then
|
||||
`code_agent_run` with `cwd = ~/.rowboat/apps/<id>/` to author
|
||||
`dist/index.html` against the verified schema + bridge contract; it can
|
||||
iterate/test on-device.
|
||||
- **No Code Mode** → Copilot writes `dist/index.html` itself (from the app
|
||||
template + bridge shim) and installs via `mini-apps:seed`.
|
||||
4. **Bridge contract** — generated app references the canonical shim
|
||||
(`app://miniapp/__bridge__.js`) and uses `window.rowboat`
|
||||
(`getData/onData`, `isConnected/connect`, `searchTools/callAction`).
|
||||
5. **Data pipeline (if agent-backed)** — create a background task (existing
|
||||
bg-tasks engine), set `manifest.agent` to its slug. The agent **returns
|
||||
schema-validated data**; the **runner writes `data.json` deterministically**
|
||||
(temp→rename, last-good on failure). App reads it via `onData`.
|
||||
6. **Finalize** — write `manifest.json` (incl. `scope`, `agent?`), ensure
|
||||
`dist/index.html`, install → app appears in the gallery (`mini-apps:list`).
|
||||
Confirm end-to-end (open it; data loads).
|
||||
|
||||
**Infra this needs (repo side):**
|
||||
- Serve the canonical bridge shim from the protocol: `app://miniapp/__bridge__.js`
|
||||
(move the shim out of per-app inlining into served infra).
|
||||
- bg-tasks runner: when a task is app-linked, validate its structured output
|
||||
against the app schema and write that app's `data.json` (deterministic write
|
||||
mode) instead of `index.md`.
|
||||
- A `build-mini-app` skill registered in the skills catalog.
|
||||
|
||||
## 3. Phase 1 — detailed implementation
|
||||
|
||||
UI-first. Everything hand-coded; no `~/.rowboat` storage, no IPC, no agent yet.
|
||||
We *do* mirror the eventual shapes so later phases slot in.
|
||||
|
||||
### 3.1 New files (renderer)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/renderer/src/mini-apps/types.ts` | `MiniApp` type (id, name, description, icon, accent, scope, html, data). |
|
||||
| `apps/renderer/src/mini-apps/registry.ts` | Hardcoded list of `MiniApp`s for Phase 1. |
|
||||
| `apps/renderer/src/mini-apps/apps/twitter-client.ts` | The sample app: self-contained HTML (React+Tailwind+Babel CDN) + static `data`. |
|
||||
| `apps/renderer/src/components/mini-apps-view.tsx` | Card grid; internal `selectedAppId` state; renders grid or open app. |
|
||||
| `apps/renderer/src/components/mini-app-frame.tsx` | Sandboxed iframe (`srcdoc`) + `window.rowboat` postMessage bridge (data wired; actions stubbed → toast/log). |
|
||||
|
||||
The bridge message protocol (host ↔ iframe), defined once and shared:
|
||||
- iframe → host: `{ type: 'rowboat:mini-app:ready' }`,
|
||||
`{ type: 'rowboat:mini-app:action', id, scope, action, args }`,
|
||||
`{ type: 'rowboat:mini-app:setState', patch }`
|
||||
- host → iframe: `{ type: 'rowboat:mini-app:data', data }`,
|
||||
`{ type: 'rowboat:mini-app:state', state }`,
|
||||
`{ type: 'rowboat:mini-app:action-result', id, ok, result?, error? }`
|
||||
|
||||
### 3.2 Wiring into `App.tsx` (mirror the `bg-tasks` view exactly)
|
||||
|
||||
Add a first-class `apps` view. Edit sites (all mirror an existing view):
|
||||
|
||||
1. **Tab path const** (~L198): add `const APPS_TAB_PATH = '__rowboat_mini_apps__'`.
|
||||
2. **Tab predicate** (~L374): `const isAppsTabPath = (path) => path === APPS_TAB_PATH`.
|
||||
3. **ViewState union** (~L636): add `| { type: 'apps' }`.
|
||||
4. **`parseDeepLink`** (~L713): add `case 'apps': return { type: 'apps' }`.
|
||||
5. **State flag** (~L825): `const [isAppsOpen, setIsAppsOpen] = useState(false)`.
|
||||
6. **Tab title** (~L1253): `if (isAppsTabPath(tab.path)) return 'Mini Apps'`.
|
||||
7. **Tab activation → flag** (~L3269, ~L3443): set `isAppsOpen` from tab path.
|
||||
8. **Closing-tab guard** (~L3376): add `&& !isAppsTabPath(closingTab.path)`.
|
||||
9. **`currentViewState`** (~L3770): `if (isAppsOpen) return { type: 'apps' }`
|
||||
(place alongside bg-tasks; add to deps array).
|
||||
10. **`ensureAppsFileTab`** (~L3853): mirror `ensureBgTasksFileTab`.
|
||||
11. **`openAppsView`** (~L3953): mirror `openBgTasksView`; reset all other flags,
|
||||
`setIsAppsOpen(true)`, `ensureAppsFileTab()`.
|
||||
12. **`applyViewState` switch** (~L4195): add `case 'apps':` mirroring `bg-tasks`.
|
||||
13. **Add `setIsAppsOpen(false)`** to every *other* view's reset block (the
|
||||
shared multi-`set...(false)` lines) so opening another view clears apps and
|
||||
closing it doesn't fall back to apps. Use targeted edits per block.
|
||||
14. **Derived booleans** that OR all view flags (isFullScreenChat,
|
||||
rightPaneAvailable, inFileView, rightPaneContext, viewOpen, keyboard guards):
|
||||
add `|| isAppsOpen` / `&& !isAppsOpen` consistently (search `isBgTasksOpen`).
|
||||
15. **Tab-path mapping** (~L4668): add `: isAppsOpen ? APPS_TAB_PATH`.
|
||||
16. **Render branch** (~L5894): add `) : isAppsOpen ? (<MiniAppsView ... />`.
|
||||
|
||||
### 3.3 Sidebar entry (`sidebar-content.tsx`)
|
||||
|
||||
- Add `onOpenApps?: () => void` prop (~L163) and destructure (~L412).
|
||||
- Add a `SidebarMenuButton` with `isActive={activeNav === 'apps'}` and a
|
||||
`LayoutGrid` (lucide) icon, label "Mini Apps", in the lower nav group near
|
||||
"Background agents" (~L830).
|
||||
- Thread `activeNav === 'apps'` from the parent (App.tsx passes `activeNav` based
|
||||
on `isAppsOpen`, mirroring how `'agents'` is derived ~L5651).
|
||||
- Wire `onOpenApps={openAppsView}` at the `SidebarContent`/home call sites
|
||||
(~L5657, ~L5832).
|
||||
|
||||
### 3.4 The sample app (twitter-client)
|
||||
|
||||
Self-contained HTML string. React + ReactDOM + Babel-standalone + Tailwind, all
|
||||
via CDN. Renders three sections — **To read / To repost / To respond** (reply
|
||||
pre-drafted) — from data delivered via `rowboat.onData`. Buttons call
|
||||
`rowboat.callAction('twitter', ...)`; in Phase 1 the host just toasts/logs and
|
||||
returns a fake ok. Static `data` lives next to the HTML.
|
||||
|
||||
> Note: CDN scripts require network + `allow-scripts` in the sandbox. The frame
|
||||
> uses `sandbox="allow-scripts allow-popups allow-forms allow-modals"` — **no**
|
||||
> `allow-same-origin` (keeps the app from reaching host cookies/storage; the
|
||||
> bridge is the only channel). Verify CDN loads under this sandbox during testing;
|
||||
> if blocked, fall back to bundling the libs locally as a renderer asset.
|
||||
|
||||
### 3.5 Verify
|
||||
|
||||
- `cd apps/x && npm run deps && npm run lint` compiles clean.
|
||||
- `npm run dev`: "Mini Apps" appears in the sidebar; opens the card grid; clicking
|
||||
the Twitter card renders the app full-screen in the pane; sections populate from
|
||||
data; clicking a button shows the stubbed action result; tabs/back/forward and
|
||||
switching to other views behave like every other view.
|
||||
|
||||
## 4. Out of scope for Phase 1 (later phases)
|
||||
|
||||
`~/.rowboat/apps/<slug>/` storage + IPC; the agent backend (reuse bg-tasks
|
||||
engine); real Composio execution through the bridge; per-app scope enforcement &
|
||||
auth prompting; generation via Copilot/Code-Mode; persistent state store;
|
||||
conversational editing & error recovery; the V2 workspace drag.
|
||||
253
apps/x/VIDEO_MODE.md
Normal file
253
apps/x/VIDEO_MODE.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Calls (Video Mode) — Deep Dive
|
||||
|
||||
Calls let the user talk to the assistant hands-free while it *sees* them
|
||||
(webcam) and their screen (screen share). There is ONE call engine —
|
||||
continuous listening, auto-submitted utterances, forced read-aloud TTS, frame
|
||||
capture — entered through four presets that differ only in starting devices.
|
||||
This doc covers the product flow, the technical pipeline, and the LLM prompt
|
||||
surface with exact pointers.
|
||||
|
||||
## Product flow
|
||||
|
||||
The composer has a **call split-button** (`chat-input-with-mentions.tsx`).
|
||||
The main click is the "work together" default — preset `share`: screen
|
||||
sharing ON, camera OFF, floating pill, so the user keeps working while the
|
||||
assistant watches along (the button tooltip discloses the screen share). The
|
||||
chevron menu holds the deviations. While a call is live the button turns red
|
||||
and ends it.
|
||||
|
||||
| Preset | Starting devices | First surface |
|
||||
|--------|------------------|---------------|
|
||||
| `share` — main click | screen on, camera off | floating pill |
|
||||
| `voice` — "Voice call" | camera off, screen off | floating mascot pill |
|
||||
| `video` — "Video call" | camera on | full-screen call |
|
||||
| `practice` — "Practice session" | camera on, + coaching persona | full-screen call |
|
||||
|
||||
**One surface rule** (`callSurface` in `App.tsx`): full screen and screen
|
||||
sharing are mutually exclusive in both directions — a full-screen call covers
|
||||
the screen, so sharing it would show the call itself.
|
||||
|
||||
- sharing → floating popout, always (pill = working)
|
||||
- not sharing → full screen unless `callMinimized` (full screen = facing
|
||||
each other)
|
||||
- expanding the pill auto-STOPS any share; minimizing the full-screen call
|
||||
auto-STARTS one (the pill exists to work together) — presenting from full
|
||||
screen likewise collapses to the pill
|
||||
- the camera toggle never changes the surface: turning it on from the pill
|
||||
puts your video IN the pill; expanding is its own explicit action
|
||||
|
||||
**Screen-share consent** is three-layered: a toast the moment any share
|
||||
starts ("Your screen is being shared… [Stop sharing]"), a persistent
|
||||
"Sharing screen" badge on the pill, and macOS's purple recording indicator.
|
||||
If the auto-share fails (Screen Recording permission not granted) the call
|
||||
starts anyway as a voice call, with a toast linking to System Settings.
|
||||
Practice/coaching is always an explicit choice — expanding to full screen
|
||||
never turns the coach on.
|
||||
|
||||
In-call controls (identical bar on both surfaces): mic mute, camera toggle
|
||||
(silhouette avatar while off, no webcam frames captured), screen share
|
||||
toggle, mascot ⇄ "R" letter avatar, end call. **Mute is a full input
|
||||
pause**, not just audio — mic audio stops reaching Deepgram
|
||||
(`useVoiceMode.setPaused`, OR'd with the automatic thinking/speaking pause)
|
||||
AND camera/screen frame capture stops (`useVideoMode.setCapturePaused`;
|
||||
`collectFrames()` returns nothing while muted, so typed messages carry no
|
||||
frames either), letting the user talk to someone in the room without the
|
||||
assistant listening in. Devices stay acquired for instant unmute (camera
|
||||
light and macOS share indicator stay on — the pill's share badge switches to
|
||||
"Sharing paused"), the status chip shows "Muted" instead of "Listening",
|
||||
and assistant output is unaffected (in-flight speech keeps playing; Stop
|
||||
handles that). Mute resets to off at call start/end. While the assistant is thinking or speaking, a
|
||||
red **Stop** button appears on the mascot tile — it silences TTS instantly,
|
||||
skips queued voice segments, and aborts the run if it's still generating
|
||||
(stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the
|
||||
assistant's spoken line run along the bottom. Typing in the composer still
|
||||
works mid-call; frames ride along with typed messages too.
|
||||
|
||||
Outside calls the composer keeps exactly one voice affordance: the **mic
|
||||
button** (push-to-talk dictation, untouched). Spoken responses exist only
|
||||
inside calls (forced full read-aloud, off on hang-up). The old video
|
||||
dropdown, talking-head toggle, read-aloud headphones toggle, and summary/full
|
||||
TTS dropdown are all retired — a per-message "read aloud" action on assistant
|
||||
messages is the planned replacement for text-in/voice-out.
|
||||
|
||||
The call button is disabled unless both voice input (Deepgram) and voice
|
||||
output (TTS) are configured. `call_started` (with `preset`) is captured in
|
||||
PostHog — the adoption metric for this feature.
|
||||
|
||||
**Popout mechanics**: a small always-on-top frameless window (camera tile
|
||||
when on + mascot tile, live caption, control bar) floating over every app —
|
||||
including Rowboat. Control-bar actions round-trip `video:popoutAction` →
|
||||
main → `video:popout-action` → app window, which owns the mic/camera/capture;
|
||||
`expand` also refocuses the app window (handled in main).
|
||||
|
||||
## Frame pipeline
|
||||
|
||||
`apps/renderer/src/hooks/useVideoMode.ts` runs one capture pipe per source
|
||||
(stream → offscreen `<video>` → canvas JPEG → ring buffer):
|
||||
|
||||
- Cadence: 1 fps (`CAPTURE_INTERVAL_MS`, line 20); ring buffer ~2 min.
|
||||
- Webcam: 512px wide, JPEG q0.65, max **12 frames/message** (lines 21, 31).
|
||||
- Screen: 1280px wide (text legibility), JPEG q0.7, max **4 frames/message**
|
||||
(lines 24, 32).
|
||||
- `collectFrames()` drains frames buffered since the last send, evenly
|
||||
sampled down to the caps, always keeping the newest; grabs one final frame
|
||||
at the moment of send. Falls back to the single latest frame for
|
||||
rapid-fire messages.
|
||||
|
||||
`App.tsx` `handlePromptSubmit` attaches the drained frames (whenever a call
|
||||
is live) to the outgoing message as `UserImagePart`s and sets
|
||||
`composition.videoMode` when the camera or screen is active, plus
|
||||
`composition.coachMode` during a practice session. Frames also become
|
||||
`isVideoFrame` display attachments (filmstrip in the transcript —
|
||||
`chat-message-attachments.tsx`; history hydration in
|
||||
`lib/run-to-conversation.ts`).
|
||||
|
||||
## Message schema & model encoding
|
||||
|
||||
- `packages/shared/src/message.ts:51` — `UserImagePart`: inline base64
|
||||
(`data`, `mediaType`), `source: 'camera' | 'screen'`, `capturedAt`. Unlike
|
||||
file attachments (path references read via the `LLMParse` tool), image
|
||||
parts go to the model as real multimodal image parts.
|
||||
- `packages/core/src/agents/runtime.ts` `convertFromMessages` (~line 1013):
|
||||
emits a context line (frame counts + time span), then labeled groups —
|
||||
a `"Webcam frames (oldest to newest):"` text part before camera images and
|
||||
a `"Screen-share frames (oldest to newest):"` text part before screen
|
||||
images — so the model never confuses the user with their screen.
|
||||
- Frames stay inline in history (no pruning) deliberately: pruning would
|
||||
bust provider prefix caching every turn and cost more than it saves.
|
||||
- The auto-permission classifier stringifies + truncates content to ~3KB per
|
||||
message, so inline base64 can't blow up its prompt.
|
||||
|
||||
## Hands-free voice loop
|
||||
|
||||
`apps/renderer/src/hooks/useVoiceMode.ts`:
|
||||
|
||||
- `startContinuous(onUtterance)` (line 404): push-to-talk params but with
|
||||
`endpointing=1800` (line 25) so thinking pauses don't cut the user off,
|
||||
plus `utterance_end_ms=2000` (line 38) as a second end-of-speech signal.
|
||||
**Gotcha:** Deepgram's `speech_final` usually arrives on a result with an
|
||||
EMPTY transcript — empty finals must reach the endpoint check or
|
||||
utterances never complete (see the NOTE in `ws.onmessage`).
|
||||
- `setPaused(true)` (line 414) while the assistant thinks/speaks: drops mic
|
||||
audio (so TTS is never transcribed back), discards half-heard buffer,
|
||||
sends Deepgram KeepAlives every 5s. `App.tsx` drives this from
|
||||
`activeIsProcessing || tts.state !== 'idle'`.
|
||||
- Mid-call socket drops reconnect after 1s; the offline audio backlog is
|
||||
capped (~30s).
|
||||
|
||||
Call lifecycle lives in `App.tsx` `startCall(preset)` / `endCall()`:
|
||||
entering a call saves/forces TTS settings, cancels any push-to-talk
|
||||
recording, and starts the continuous loop; ending restores everything.
|
||||
Push-to-talk is disabled while a call owns the mic.
|
||||
|
||||
## Popout window
|
||||
|
||||
- The popout window keeps the Dock icon alive: it uses
|
||||
`setVisibleOnAllWorkspaces(true)` WITHOUT `visibleOnFullScreen` — that flag
|
||||
turns the app into a macOS "agent" app and hides its Dock icon while the
|
||||
window exists (looks like Rowboat vanished). Trade-off: the popout doesn't
|
||||
hover over other apps' fullscreen Spaces.
|
||||
- Shown iff the derived `callSurface === 'popout'` (effect in `App.tsx`).
|
||||
Renderer asks `video:setPopout {show}`; main creates a frameless,
|
||||
`alwaysOnTop` ('floating'), all-workspaces BrowserWindow at the top-right
|
||||
of the primary display, loading the renderer bundle with `#video-popout`
|
||||
(`apps/renderer/src/main.tsx` branches on the hash →
|
||||
`components/video-popout.tsx`).
|
||||
- Call state streams over the `video:popout-state` push channel; main caches
|
||||
the last payload and replays it on popout load. Shown with
|
||||
`showInactive()` so it never steals focus.
|
||||
- The popout captures its **own** camera preview (MediaStreams can't cross
|
||||
windows) and synthesizes the mascot mouth level (no audio in that window).
|
||||
- `video:popoutAction` relays control-bar actions to the app window, matched
|
||||
only by real app-window URLs — `getAllWindows()` also contains hidden
|
||||
utility windows (PDF export) that must not be shown or messaged.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Camera: `voice:ensureCameraAccess` settles the macOS TCC prompt before
|
||||
`getUserMedia` (same pattern as the mic). `NSCameraUsageDescription` is in
|
||||
`forge.config.cjs` `extendInfo`.
|
||||
- Screen: `getDisplayMedia` is auto-approved with the primary screen by
|
||||
`setDisplayMediaRequestHandler` in `main.ts` (no picker);
|
||||
`meeting:checkScreenPermission` registers the app in macOS Screen
|
||||
Recording settings on first use.
|
||||
|
||||
## LLM prompts catalog
|
||||
|
||||
| Prompt | Where |
|
||||
|--------|-------|
|
||||
| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/agents/runtime.ts:386` (`composeSystemInstructions`, gated on `videoMode`) |
|
||||
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `composeSystemInstructions`, gated on `coachMode` (directly after the video section) |
|
||||
| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`application/assistant/skills/app-navigation/skill.ts`) |
|
||||
| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/agents/runtime.ts` (`convertFromMessages`) |
|
||||
| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
|
||||
|
||||
Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are
|
||||
reused untouched — calls set `voiceInput` per utterance and force
|
||||
`voiceOutput: 'full'`.
|
||||
|
||||
## Driving the app on a call
|
||||
|
||||
The assistant can drive the Rowboat UI itself via the extended
|
||||
`app-navigation` builtin ("app driver"): `open-view` (any main view),
|
||||
`read-view` (returns the emails / background agents / chat-history data the
|
||||
view renders — and the renderer simultaneously navigates there so the user
|
||||
watches it happen), and `open-item` (a specific email thread, note,
|
||||
background agent, or past chat, deep-linked on screen). Data comes from the
|
||||
same core functions the UI's IPC handlers use (`listImportantThreads` /
|
||||
`searchThreads`, background-task `listTasks`, the sessions container) — no
|
||||
OCR of screen frames. The renderer applies results via
|
||||
`applyAppNavigation` in App.tsx, fed from BOTH event paths: the legacy
|
||||
`runs:events` ref-poll AND a watcher over the session-chat conversation (the
|
||||
turn runtime does not emit legacy run events — miss this and navigation
|
||||
silently no-ops while the tool reports success). Session switches seed the
|
||||
watcher so replaying history never navigates. During a call, visible
|
||||
navigations also collapse the full-screen call to the pill and focus the app
|
||||
window (`app:focusMainWindow`) so the user actually sees the screen change.
|
||||
Card labels live in `lib/chat-conversation.ts`. The call prompt and the
|
||||
`app-navigation` skill teach the show-while-telling pattern: read-view →
|
||||
speak the highlights → open-item when the user picks one.
|
||||
|
||||
## Latency
|
||||
|
||||
Voice-to-voice latency (user stops talking → assistant audio) is engineered
|
||||
at four points; the `call_turn_latency` PostHog event measures the real
|
||||
distribution (utterance → submit → first speak → audio playing):
|
||||
|
||||
- **Smart endpointing** (`useVoiceMode.ts`): Deepgram endpoints at 600ms and
|
||||
the client decides — a transcript ending in terminal punctuation fires
|
||||
immediately (~600ms after last word); a mid-thought trail holds another
|
||||
1.2s (resumed speech cancels the hold). Complete sentences turn around
|
||||
~1.2s faster than the old fixed 1800ms endpoint.
|
||||
- **Streaming TTS** (`voice:synthesizeStreamStart` → `voice:tts-chunk` →
|
||||
MediaSource playback in `useVoiceTTS.ts`): the first segment of an idle
|
||||
queue plays from the first MP3 chunk instead of after the full body
|
||||
(ElevenLabs `/stream`, flash model). Follow-up segments keep the gapless
|
||||
full-body prefetch path. Falls back to non-streaming on any failure.
|
||||
- **Early clause speech** (`turn-view.ts` `applyOverlay`): a still-open
|
||||
`<voice>` block ≥60 chars emits its last complete clause immediately, so
|
||||
speech starts while the rest of the sentence generates.
|
||||
- **Acknowledgment cue** (`lib/call-sounds.ts`): a soft blip the instant an
|
||||
utterance is accepted — perceived latency matters as much as measured.
|
||||
|
||||
## Cost notes
|
||||
|
||||
Webcam frames ≈ 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.
|
||||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||
// The banner defines __import_meta_url at the top of the bundle,
|
||||
|
|
@ -24,7 +28,11 @@ await esbuild.build({
|
|||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: './.package/dist/main.cjs',
|
||||
external: ['electron'], // Provided by Electron runtime
|
||||
// electron is provided by the runtime. node-pty is a NATIVE module: it can't
|
||||
// be inlined (its loader requires .node binaries + a spawn-helper relative to
|
||||
// its own package dir), so it stays external and is copied into
|
||||
// .package/node_modules below, where require() from dist/main.cjs finds it.
|
||||
external: ['electron', 'node-pty'],
|
||||
// Use CommonJS format - many dependencies use require() which doesn't work
|
||||
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
format: 'cjs',
|
||||
|
|
@ -42,4 +50,73 @@ await esbuild.build({
|
|||
},
|
||||
});
|
||||
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
|
||||
// package dir and copy only what's needed at runtime (compiled JS + prebuilt
|
||||
// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
|
||||
// the bit, and a non-executable helper makes every PTY spawn fail.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
|
||||
const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
|
||||
fs.rmSync(ptyDest, { recursive: true, force: true });
|
||||
fs.mkdirSync(ptyDest, { recursive: true });
|
||||
for (const item of ['package.json', 'lib', 'prebuilds']) {
|
||||
fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
|
||||
}
|
||||
const prebuildsDir = path.join(ptyDest, 'prebuilds');
|
||||
for (const dir of fs.readdirSync(prebuildsDir)) {
|
||||
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
|
||||
// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any
|
||||
// host whose prebuild is absent (notably Linux) the staged package has no loadable
|
||||
// pty.node and the app crashes on launch. Compile the native module for the host
|
||||
// platform+arch if needed and stage it under prebuilds/<platform>-<arch>/, where
|
||||
// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp
|
||||
// step (the CI workflow's explicit build is the fast path; this is the safety net).
|
||||
const hostTriple = `${process.platform}-${process.arch}`;
|
||||
const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node');
|
||||
if (!fs.existsSync(stagedBinary)) {
|
||||
const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node');
|
||||
if (!fs.existsSync(builtBinary)) {
|
||||
console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`);
|
||||
execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' });
|
||||
}
|
||||
if (!fs.existsSync(builtBinary)) {
|
||||
throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(stagedBinary), { recursive: true });
|
||||
fs.copyFileSync(builtBinary, stagedBinary);
|
||||
console.log(`✅ node-pty: staged ${hostTriple}/pty.node`);
|
||||
}
|
||||
console.log('✅ node-pty staged in .package/node_modules');
|
||||
|
||||
// Bundle the vendored agent-slack CLI into a single self-contained script next
|
||||
// to main.cjs. It runs as a child process (process.execPath with
|
||||
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
|
||||
// be inlined into main.cjs. Bundling here means the packaged app needs neither
|
||||
// node_modules nor a global npm install.
|
||||
const agentSlackPkg = JSON.parse(
|
||||
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
await esbuild.build({
|
||||
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
outfile: './.package/dist/agent-slack.cjs',
|
||||
format: 'cjs',
|
||||
banner: { js: cjsBanner },
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
// Without this constant the CLI's --version walks up the directory tree
|
||||
// for a package.json and would find Rowboat's instead of agent-slack's.
|
||||
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
|
||||
},
|
||||
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
|
||||
// back to node:sqlite; keep it external so the probe fails at runtime the
|
||||
// same way it does under plain node.
|
||||
external: ['bun:sqlite'],
|
||||
});
|
||||
|
||||
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,189 @@
|
|||
const path = require('path');
|
||||
const pkg = require('./package.json');
|
||||
|
||||
// The Arch Linux (pacman) package is meant only for local builds on an Arch host
|
||||
// with makepkg. It already self-skips elsewhere (maker-pacman checks for makepkg),
|
||||
// but CI sets ROWBOAT_SKIP_PACMAN=1 to disable it explicitly — GitHub runners are
|
||||
// Ubuntu and shouldn't attempt to ship an Arch package.
|
||||
const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1';
|
||||
|
||||
// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full
|
||||
// production dependency closure into the packaged app.
|
||||
//
|
||||
// Why this is needed: code mode spawns each adapter as a SEPARATE `node <entry>`
|
||||
// process and locates it at runtime via require.resolve — so it must ship as a real
|
||||
// on-disk file. esbuild can't inline it (dynamic resolve + spawn target), and Forge
|
||||
// strips the workspace node_modules (see `ignore` below). Without this, packaged
|
||||
// builds throw `Cannot find module '@agentclientprotocol/...'`.
|
||||
//
|
||||
// Why we reconstruct the tree instead of copying node_modules: pnpm's store is a
|
||||
// symlink farm that legitimately holds multiple versions of the same package (e.g.
|
||||
// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style
|
||||
// node_modules — dereferencing symlinks — that resolves correctly regardless of pnpm
|
||||
// layout. We HOIST every package to the top-level node_modules and only nest a package
|
||||
// under its requirer on a genuine version conflict. Hoisting (vs. always nesting) keeps
|
||||
// the tree shallow: without it, transitive chains like codex-acp → open → wsl-utils →
|
||||
// is-wsl → is-inside-container → is-docker nest 5+ deep and produce ~260-char paths that
|
||||
// break the Windows Squirrel/nuget maker's MAX_PATH limit. Node resolution stays correct
|
||||
// because the top-level node_modules is an ancestor of every staged file, so a hoisted
|
||||
// package resolves for all requirers and a conflicting version shadows it via nesting.
|
||||
// verifyAcpStaging() below asserts this held for every dependency edge.
|
||||
//
|
||||
// What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped
|
||||
// as platform-specific packages). Those are PROVISIONED on demand into
|
||||
// ~/.rowboat/engines/<agent>/<version>/ and the adapters are pointed at them via
|
||||
// CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping
|
||||
// them keeps each OS installer ~400 MB smaller while code mode stays fully functional.
|
||||
// Shared by stageAcpAdapters and verifyAcpStaging so staging and verification use
|
||||
// identical resolution semantics.
|
||||
const ACP_ADAPTERS = [
|
||||
'@agentclientprotocol/claude-agent-acp',
|
||||
'@agentclientprotocol/codex-acp',
|
||||
];
|
||||
|
||||
// The native engines, shipped as platform packages. Provisioned on demand
|
||||
// (see header comment), so they're excluded from staging.
|
||||
const isAcpNativeEngine = (key) =>
|
||||
/^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude
|
||||
/^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex
|
||||
|
||||
// Resolve a dependency's real directory by walking node_modules the way Node does,
|
||||
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||
// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
|
||||
// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
|
||||
// Returns null for deps not installed for this OS (platform-optional binaries).
|
||||
const acpRealDirOf = (key, fromDir) => {
|
||||
const fs = require('fs');
|
||||
let dir = fromDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
function stageAcpAdapters(mainDir, destNodeModules) {
|
||||
const fs = require('fs');
|
||||
|
||||
let copied = 0;
|
||||
const skippedEngines = new Set();
|
||||
// srcRealDir -> the staged directory whose content represents it. Lets
|
||||
// verifyAcpStaging map every source package to where it landed.
|
||||
const placements = new Map();
|
||||
// package key -> version placed at the TOP-LEVEL node_modules. We hoist every
|
||||
// package to the top level and only nest a package under its requirer when a
|
||||
// DIFFERENT version is already hoisted there. See the header comment for why
|
||||
// (a shallow tree stays under Windows' MAX_PATH).
|
||||
const rootHoisted = new Map();
|
||||
const install = (srcDir, key, parentNM, chain) => {
|
||||
if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const version = pj.version;
|
||||
const hoisted = rootHoisted.get(key);
|
||||
let destNM;
|
||||
if (hoisted === undefined) {
|
||||
destNM = destNodeModules; // first sighting → hoist to the top level
|
||||
rootHoisted.set(key, version);
|
||||
} else if (hoisted === version) {
|
||||
// identical version already hoisted at root → reuse it (its subtree is
|
||||
// already staged); just record where this srcDir resolves to.
|
||||
placements.set(srcDir, path.join(destNodeModules, ...key.split('/')));
|
||||
return;
|
||||
} else {
|
||||
destNM = parentNM; // genuine version conflict → nest under requirer
|
||||
}
|
||||
const destDir = path.join(destNM, ...key.split('/'));
|
||||
placements.set(srcDir, destDir);
|
||||
if (fs.existsSync(destDir)) return; // already placed at this exact location
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
fs.cpSync(srcDir, destDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion
|
||||
});
|
||||
copied++;
|
||||
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||
const nextChain = new Set(chain).add(srcDir);
|
||||
for (const depKey of Object.keys(deps)) {
|
||||
if (isAcpNativeEngine(depKey)) { skippedEngines.add(depKey); continue; }
|
||||
const depDir = acpRealDirOf(depKey, srcDir);
|
||||
if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of ACP_ADAPTERS) {
|
||||
const srcDir = acpRealDirOf(key, mainDir);
|
||||
if (!srcDir) {
|
||||
throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`);
|
||||
}
|
||||
install(srcDir, key, destNodeModules, new Set());
|
||||
}
|
||||
if (skippedEngines.size) {
|
||||
console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`);
|
||||
}
|
||||
return { copied, placements };
|
||||
}
|
||||
|
||||
// Fail the build LOUDLY if hoisting misplaced anything. Re-walk the source dependency
|
||||
// closure and assert that every (package → dependency) edge resolves, in the STAGED
|
||||
// tree, to the SAME version it resolves to in the SOURCE pnpm tree. This converts a
|
||||
// silent runtime "Cannot find module" (or a wrong-version resolution from a botched
|
||||
// hoist) into an immediate build failure. Expectations are derived from the source
|
||||
// tree — nothing is hardcoded — so it keeps working as the dependency set changes.
|
||||
function verifyAcpStaging(mainDir, placements) {
|
||||
const fs = require('fs');
|
||||
const versionAt = (dir) =>
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
||||
// Resolve `key`'s staged version as seen from `fromStagedDir`, via Node's own
|
||||
// upward node_modules walk. Reads package.json directly (not require.resolve, whose
|
||||
// `${key}/package.json` subpath some exports maps block).
|
||||
const stagedVersionOf = (key, fromStagedDir) => {
|
||||
let dir = fromStagedDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return versionAt(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
const errors = [];
|
||||
const visited = new Set();
|
||||
const walk = (srcDir) => {
|
||||
if (visited.has(srcDir)) return;
|
||||
visited.add(srcDir);
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const stagedDir = placements.get(srcDir);
|
||||
if (!stagedDir) { errors.push(`not staged: ${pj.name}@${pj.version}`); return; }
|
||||
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||
for (const depKey of Object.keys(deps)) {
|
||||
if (isAcpNativeEngine(depKey)) continue;
|
||||
const depSrc = acpRealDirOf(depKey, srcDir);
|
||||
if (!depSrc) continue; // platform-optional / not installed for this OS
|
||||
const want = versionAt(depSrc);
|
||||
const got = stagedVersionOf(depKey, stagedDir);
|
||||
if (got === null) {
|
||||
errors.push(`${pj.name} → ${depKey}: unresolved in staged tree (expected ${want})`);
|
||||
} else if (got !== want) {
|
||||
errors.push(`${pj.name} → ${depKey}: staged resolves ${got}, source resolves ${want}`);
|
||||
}
|
||||
walk(depSrc);
|
||||
}
|
||||
};
|
||||
for (const key of ACP_ADAPTERS) {
|
||||
const srcDir = acpRealDirOf(key, mainDir);
|
||||
if (srcDir) walk(srcDir);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error(
|
||||
`ACP staging verification failed — the staged tree resolves differently than source:\n - ${errors.join('\n - ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
executableName: 'rowboat',
|
||||
|
|
@ -16,6 +199,7 @@ module.exports = {
|
|||
],
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
NSCameraUsageDescription: 'Rowboat uses your camera in video chat mode so the assistant can see you and give feedback (e.g. pitch practice).',
|
||||
},
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
|
|
@ -29,17 +213,23 @@ module.exports = {
|
|||
appleIdPassword: process.env.APPLE_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID
|
||||
},
|
||||
// Since we bundle everything with esbuild, we don't need node_modules at all.
|
||||
// These settings prevent Forge's dependency walker (flora-colossus) from trying
|
||||
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
|
||||
// Since we bundle the main process with esbuild, we don't need the workspace
|
||||
// node_modules. These settings prevent Forge's dependency walker (flora-colossus)
|
||||
// from trying to analyze/copy node_modules, which fails with pnpm's symlinked
|
||||
// workspaces.
|
||||
prune: false,
|
||||
ignore: [
|
||||
/src\//,
|
||||
/node_modules\//,
|
||||
/.gitignore/,
|
||||
/bundle\.mjs/,
|
||||
/tsconfig.json/,
|
||||
],
|
||||
// Strip the workspace src/node_modules (paths are ANCHORED to the app root), BUT
|
||||
// always keep everything under `.package/` — that's our staged output: the
|
||||
// bundled main process, the ACP adapters + their dependency closure (staged by
|
||||
// the generateAssets hook), and the native node-pty module (staged into
|
||||
// .package/node_modules by bundle.mjs). Without the `.package` exemption the
|
||||
// node_modules rule would strip those and code mode / the embedded terminal
|
||||
// would break in packaged builds.
|
||||
ignore: (p) => {
|
||||
if (p === '/.package' || p.startsWith('/.package/')) return false;
|
||||
return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
|
||||
.some((re) => re.test(p));
|
||||
},
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
|
|
@ -86,6 +276,22 @@ module.exports = {
|
|||
}
|
||||
}
|
||||
},
|
||||
// Arch Linux package — local-only; disabled in CI via ROWBOAT_SKIP_PACMAN.
|
||||
...(SKIP_PACMAN ? [] : [{
|
||||
name: require.resolve('./makers/maker-pacman.cjs'),
|
||||
platforms: ['linux'],
|
||||
config: {
|
||||
name: 'rowboat',
|
||||
bin: 'rowboat',
|
||||
executableName: 'rowboat',
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
license: 'Apache',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
}]),
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platform: ["darwin", "win32", "linux"],
|
||||
|
|
@ -178,7 +384,18 @@ module.exports = {
|
|||
fs.mkdirSync(rendererDest, { recursive: true });
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
|
||||
// Stage the ACP coding-adapters (+ their JS dependency closure, minus native
|
||||
// engines) into .package/acp/node_modules. They are spawned as separate node
|
||||
// processes at runtime and Forge strips the workspace node_modules, so they
|
||||
// must be copied in explicitly. See stageAcpAdapters() above for the why.
|
||||
console.log('Staging ACP adapters...');
|
||||
const acpDest = path.join(packageDir, 'acp', 'node_modules');
|
||||
const { copied: staged, placements } = stageAcpAdapters(__dirname, acpDest);
|
||||
// Assert the hoisted tree resolves identically to source before shipping it.
|
||||
verifyAcpStaging(__dirname, placements);
|
||||
console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules (resolution verified)`);
|
||||
|
||||
console.log('✅ All assets staged in .package/');
|
||||
},
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages
|
||||
// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host).
|
||||
//
|
||||
// CJS on purpose: forge.config.cjs require()s us.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const MakerBase = require('@electron-forge/maker-base').default;
|
||||
|
||||
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' };
|
||||
|
||||
class MakerPacman extends MakerBase {
|
||||
name = 'pacman';
|
||||
defaultPlatforms = ['linux'];
|
||||
|
||||
isSupportedOnCurrentPlatform() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try {
|
||||
execSync('command -v makepkg', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async make({ dir, makeDir, targetArch, packageJSON, appName }) {
|
||||
const pkgArch = ARCH_MAP[targetArch] || targetArch;
|
||||
|
||||
const cfg = this.config || {};
|
||||
const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase();
|
||||
// pacman pkgver disallows '-'; map prerelease tags through.
|
||||
const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_');
|
||||
const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"');
|
||||
const maintainer = cfg.maintainer || 'unknown';
|
||||
const homepage = cfg.homepage || packageJSON.homepage || '';
|
||||
const license = cfg.license || 'custom';
|
||||
const bin = cfg.bin || pkgName;
|
||||
const execName = cfg.executableName || appName || pkgName;
|
||||
const mimeTypes = cfg.mimeType || [];
|
||||
const depends = cfg.depends || [];
|
||||
const iconSrc = cfg.icon;
|
||||
|
||||
const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch));
|
||||
await this.ensureDirectory(outDir);
|
||||
|
||||
// Clean prior contents so makepkg starts fresh each run.
|
||||
for (const f of fs.readdirSync(outDir)) {
|
||||
fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs).
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, bin),
|
||||
`#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const desktop = [
|
||||
'[Desktop Entry]',
|
||||
`Name=${appName || pkgName}`,
|
||||
`Comment=${pkgDesc}`,
|
||||
`Exec=${bin} %U`,
|
||||
`Icon=${pkgName}`,
|
||||
'Type=Application',
|
||||
'Categories=Utility;',
|
||||
'Terminal=false',
|
||||
mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null,
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop);
|
||||
|
||||
const sources = [bin, `${pkgName}.desktop`];
|
||||
let iconInstall = '';
|
||||
if (iconSrc && fs.existsSync(iconSrc)) {
|
||||
fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png'));
|
||||
sources.push('icon.png');
|
||||
iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`;
|
||||
}
|
||||
|
||||
const sumsLine = sources.map(() => "'SKIP'").join(' ');
|
||||
const sourceLine = sources.map((s) => `'${s}'`).join(' ');
|
||||
const dependsLine = depends.map((d) => `'${d}'`).join(' ');
|
||||
// Embed the packager output dir as a bash-safe literal.
|
||||
const appDirEscaped = dir.replace(/'/g, `'\\''`);
|
||||
|
||||
const pkgbuild = `# Maintainer: ${maintainer}
|
||||
# Auto-generated by maker-pacman.cjs — do not edit by hand.
|
||||
pkgname=${pkgName}
|
||||
pkgver=${pkgVersion}
|
||||
pkgrel=1
|
||||
pkgdesc="${pkgDesc}"
|
||||
arch=('${pkgArch}')
|
||||
url="${homepage}"
|
||||
license=('${license}')
|
||||
depends=(${dependsLine})
|
||||
options=('!strip' '!debug')
|
||||
source=(${sourceLine})
|
||||
sha256sums=(${sumsLine})
|
||||
|
||||
_appdir='${appDirEscaped}'
|
||||
|
||||
package() {
|
||||
install -dm755 "$pkgdir/opt/$pkgname"
|
||||
cp -a "$_appdir/." "$pkgdir/opt/$pkgname/"
|
||||
|
||||
# Electron's sandbox helper needs setuid root for the namespace sandbox.
|
||||
if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then
|
||||
chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
|
||||
fi
|
||||
|
||||
install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}"
|
||||
install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop"
|
||||
${iconInstall}
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild);
|
||||
|
||||
execSync('makepkg -f --noconfirm --nodeps', {
|
||||
cwd: outDir,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch },
|
||||
});
|
||||
|
||||
return fs
|
||||
.readdirSync(outDir)
|
||||
.filter((f) => f.endsWith('.pkg.tar.zst'))
|
||||
.map((f) => path.join(outDir, f));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MakerPacman;
|
||||
module.exports.default = MakerPacman;
|
||||
|
|
@ -13,14 +13,16 @@
|
|||
"make": "electron-forge make"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.55.0",
|
||||
"@agentclientprotocol/codex-acp": "^1.1.0",
|
||||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"agent-slack": "0.9.3",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"update-electron-app": "^3.1.2",
|
||||
|
|
@ -29,6 +31,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-base": "^7.11.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import { browserViewManager, type BrowserState } from './view.js';
|
||||
import { browserViewManager, type BrowserState, type HttpAuthRequest } from './view.js';
|
||||
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ type BrowserHandlers = {
|
|||
'browser:forward': InvokeHandler<'browser:forward'>;
|
||||
'browser:reload': InvokeHandler<'browser:reload'>;
|
||||
'browser:getState': InvokeHandler<'browser:getState'>;
|
||||
'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +63,9 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
'browser:getState': async () => {
|
||||
return browserViewManager.getState();
|
||||
},
|
||||
'browser:httpAuthResponse': async (_event, args) => {
|
||||
return browserViewManager.respondToHttpAuth(args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -70,12 +74,26 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
* window is created so the manager has a window to attach to.
|
||||
*/
|
||||
export function setupBrowserEventForwarding(): void {
|
||||
browserViewManager.on('state-updated', (state: BrowserState) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('browser:didUpdateState', state);
|
||||
}
|
||||
// Only send to app windows, never to OAuth/SSO popup windows created by
|
||||
// page window.open() — those render untrusted web content, and browsing
|
||||
// state / auth-challenge metadata must not cross into them.
|
||||
const broadcast = (channel: string, payload: unknown) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed() || !win.webContents) continue;
|
||||
if (browserViewManager.isPopupWindow(win)) continue;
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
};
|
||||
|
||||
browserViewManager.on('state-updated', (state: BrowserState) => {
|
||||
broadcast('browser:didUpdateState', state);
|
||||
});
|
||||
|
||||
browserViewManager.on('http-auth-request', (request: HttpAuthRequest) => {
|
||||
broadcast('browser:httpAuthRequest', request);
|
||||
});
|
||||
|
||||
browserViewManager.on('http-auth-resolved', (requestId: string) => {
|
||||
broadcast('browser:httpAuthResolved', { requestId });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session, type WebContents } from 'electron';
|
||||
import type {
|
||||
BrowserPageElement,
|
||||
BrowserPageSnapshot,
|
||||
BrowserState,
|
||||
BrowserTabState,
|
||||
HttpAuthRequest,
|
||||
} from '@x/shared/dist/browser-control.js';
|
||||
import { normalizeNavigationTarget } from './navigation.js';
|
||||
import {
|
||||
|
|
@ -20,7 +21,7 @@ import {
|
|||
type RawBrowserPageSnapshot,
|
||||
} from './page-scripts.js';
|
||||
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState, HttpAuthRequest };
|
||||
|
||||
/**
|
||||
* Embedded browser pane implementation.
|
||||
|
|
@ -36,13 +37,33 @@ export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
|
|||
|
||||
export const BROWSER_PARTITION = 'persist:rowboat-browser';
|
||||
|
||||
// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
|
||||
// that sniff the UA looking for "real browser" shapes.
|
||||
const SPOOF_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
|
||||
// Spoof a real Chrome UA so OAuth servers don't reject the embedded browser.
|
||||
// The Chrome major version is derived from the running Chromium at startup:
|
||||
// pinning a fixed version goes stale as Electron upgrades, and Chromium keeps
|
||||
// emitting Sec-CH-UA client hints with the *real* version — a UA/client-hint
|
||||
// version mismatch is a classic bot-detection signal (Google sign-in,
|
||||
// Cloudflare). Minor version is frozen at 0.0.0, exactly like real Chrome's
|
||||
// reduced UA. The platform token matches the actual OS for the same reason.
|
||||
function getChromeMajorVersion(): number {
|
||||
const major = Number.parseInt(process.versions.chrome ?? '', 10);
|
||||
return Number.isFinite(major) && major > 0 ? major : 130;
|
||||
}
|
||||
|
||||
function buildChromeUserAgent(): string {
|
||||
const platformToken =
|
||||
process.platform === 'darwin'
|
||||
? 'Macintosh; Intel Mac OS X 10_15_7'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows NT 10.0; Win64; x64'
|
||||
: 'X11; Linux x86_64';
|
||||
return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${getChromeMajorVersion()}.0.0.0 Safari/537.36`;
|
||||
}
|
||||
|
||||
const SPOOF_UA = buildChromeUserAgent();
|
||||
|
||||
const HOME_URL = 'https://www.google.com';
|
||||
const NAVIGATION_TIMEOUT_MS = 10000;
|
||||
const HTTP_AUTH_TIMEOUT_MS = 120000;
|
||||
const POST_ACTION_IDLE_MS = 400;
|
||||
const POST_ACTION_MAX_ELEMENTS = 25;
|
||||
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
|
||||
|
|
@ -68,6 +89,13 @@ type CachedSnapshot = {
|
|||
elements: Array<{ index: number; selector: string }>;
|
||||
};
|
||||
|
||||
type PendingHttpAuth = {
|
||||
callback: (username?: string, password?: string) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
// The webContents that raised the challenge, so its teardown can cancel it.
|
||||
webContents: WebContents;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
|
|
@ -109,6 +137,12 @@ export class BrowserViewManager extends EventEmitter {
|
|||
private visible = false;
|
||||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
private snapshotCache = new Map<string, CachedSnapshot>();
|
||||
private pendingHttpAuth = new Map<string, PendingHttpAuth>();
|
||||
// Child windows created by page window.open() (OAuth/SSO popups). Tracked so
|
||||
// they can be closed when the host window goes away — otherwise an orphaned
|
||||
// popup keeps BrowserWindow.getAllWindows() non-empty and, on macOS, blocks
|
||||
// the app from reopening via the Dock (see main.ts 'activate' handler).
|
||||
private popupWindows = new Set<BrowserWindow>();
|
||||
private cleanupWindowListeners: (() => void) | null = null;
|
||||
|
||||
attach(window: BrowserWindow): void {
|
||||
|
|
@ -137,6 +171,7 @@ export class BrowserViewManager extends EventEmitter {
|
|||
if (this.window !== window) return;
|
||||
|
||||
const tabs = [...this.tabs.values()];
|
||||
const popups = [...this.popupWindows];
|
||||
this.cleanupWindowListeners = null;
|
||||
this.window = null;
|
||||
this.browserSession = null;
|
||||
|
|
@ -150,6 +185,14 @@ export class BrowserViewManager extends EventEmitter {
|
|||
this.attachedTabId = null;
|
||||
this.visible = false;
|
||||
this.snapshotCache.clear();
|
||||
for (const requestId of [...this.pendingHttpAuth.keys()]) {
|
||||
this.finishHttpAuth(requestId);
|
||||
}
|
||||
// Close any OAuth/SSO popups so they don't outlive the app window.
|
||||
for (const popup of popups) {
|
||||
if (!popup.isDestroyed()) popup.close();
|
||||
}
|
||||
this.popupWindows.clear();
|
||||
};
|
||||
|
||||
hostWebContents.on('did-start-loading', handleDidStartLoading);
|
||||
|
|
@ -171,6 +214,36 @@ export class BrowserViewManager extends EventEmitter {
|
|||
if (this.browserSession) return this.browserSession;
|
||||
const browserSession = session.fromPartition(BROWSER_PARTITION);
|
||||
browserSession.setUserAgent(SPOOF_UA);
|
||||
|
||||
// Electron's Sec-CH-UA client hints only carry the "Chromium" brand;
|
||||
// real Chrome also sends "Google Chrome". Some sign-in flows (notably
|
||||
// Google's) distinguish the two, so rewrite the brand list to match what
|
||||
// Chrome sends. Both the low-entropy header (`sec-ch-ua`, major versions)
|
||||
// and the high-entropy one (`sec-ch-ua-full-version-list`, requested via
|
||||
// Accept-CH and carrying full versions) must be rewritten together — a
|
||||
// header that claims "Google Chrome" alongside one that doesn't is a
|
||||
// stronger bot signal than the original. Only headers Chromium already
|
||||
// attached are rewritten — none are added. (navigator.userAgentData JS
|
||||
// brands still report only Chromium; there is no reliable hook to spoof
|
||||
// that under sandbox+contextIsolation, and header-based detection is the
|
||||
// common case.)
|
||||
const chromeMajor = getChromeMajorVersion();
|
||||
const chromeFull = process.versions.chrome ?? `${chromeMajor}.0.0.0`;
|
||||
const brandLists: Record<string, string> = {
|
||||
'sec-ch-ua': `"Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}", "Not-A.Brand";v="99"`,
|
||||
'sec-ch-ua-full-version-list': `"Chromium";v="${chromeFull}", "Google Chrome";v="${chromeFull}", "Not-A.Brand";v="99.0.0.0"`,
|
||||
};
|
||||
browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const requestHeaders = details.requestHeaders;
|
||||
for (const name of Object.keys(requestHeaders)) {
|
||||
const replacement = brandLists[name.toLowerCase()];
|
||||
if (replacement !== undefined) {
|
||||
requestHeaders[name] = replacement;
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders });
|
||||
});
|
||||
|
||||
this.browserSession = browserSession;
|
||||
return browserSession;
|
||||
}
|
||||
|
|
@ -196,17 +269,34 @@ export class BrowserViewManager extends EventEmitter {
|
|||
return /^https?:\/\//i.test(url) || url === 'about:blank';
|
||||
}
|
||||
|
||||
/**
|
||||
* webPreferences shared by browser tabs and OAuth popups. Kept in one place
|
||||
* so the security-sensitive popup surface can never drift from tabs.
|
||||
*/
|
||||
private browserWebPreferences(): Electron.WebPreferences {
|
||||
return {
|
||||
session: this.getSession(),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
// Chromium's built-in PDFium viewer, so PDFs render inline instead
|
||||
// of showing a blank page.
|
||||
plugins: true,
|
||||
// Remove the WebAuthn API from the embedded browser only. Electron ships
|
||||
// the API but not Chrome's authenticator UI (Touch ID sheet, QR/phone
|
||||
// hybrid), so passkey challenges hang forever on "Verifying it's you...".
|
||||
// With the API absent, sites feature-detect it and fall back to
|
||||
// password/other verification. Scoped here (not app-wide) so the app's
|
||||
// own renderer keeps WebAuthn.
|
||||
disableBlinkFeatures: 'WebAuth',
|
||||
};
|
||||
}
|
||||
|
||||
private createView(): WebContentsView {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: this.getSession(),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
webPreferences: this.browserWebPreferences(),
|
||||
});
|
||||
|
||||
view.webContents.setUserAgent(SPOOF_UA);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
|
@ -266,16 +356,145 @@ export class BrowserViewManager extends EventEmitter {
|
|||
});
|
||||
wc.on('page-title-updated', this.emitState.bind(this));
|
||||
|
||||
wc.setWindowOpenHandler(({ url }) => {
|
||||
if (this.isEmbeddedTabUrl(url)) {
|
||||
void this.newTab(url);
|
||||
} else {
|
||||
void shell.openExternal(url);
|
||||
this.wireWindowPolicy(wc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-open, popup, and HTTP-auth wiring shared by tabs and popups.
|
||||
*/
|
||||
private wireWindowPolicy(wc: WebContents): void {
|
||||
wc.setWindowOpenHandler((details) => this.handleWindowOpen(details));
|
||||
wc.on('did-create-window', (child) => this.wirePopupWindow(child));
|
||||
this.wireHttpAuth(wc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared window.open / target=_blank policy for tabs and popups.
|
||||
*
|
||||
* An open that hands a handle back to the opener must become a real child
|
||||
* window so window.opener / postMessage survive — this is how OAuth/SSO
|
||||
* popups (Google, Microsoft, Plaid, ...) return their result; denying them
|
||||
* also makes sites report "popup blocked". Those are: a sized popup
|
||||
* (disposition 'new-window'), a *named* window.open(url, 'name') (non-empty
|
||||
* frameName), or a scripted blank window the opener will populate
|
||||
* (about:blank). A nameless target=_blank link (foreground-tab, empty
|
||||
* frameName) has no opener contract and opens as a tab, matching browser
|
||||
* behavior. Non-web schemes go to the system handler.
|
||||
*
|
||||
* Residual gap: a nameless, featureless window.open(url) is indistinguishable
|
||||
* from a _blank link (both foreground-tab + empty frameName) and opens as a
|
||||
* tab, losing its opener — rare for OAuth, which virtually always names or
|
||||
* sizes its popup.
|
||||
*/
|
||||
private handleWindowOpen(details: Electron.HandlerDetails): Electron.WindowOpenHandlerResponse {
|
||||
const { url, disposition, frameName } = details;
|
||||
|
||||
if (this.isEmbeddedTabUrl(url)) {
|
||||
const needsOpener =
|
||||
disposition === 'new-window' || frameName !== '' || url === 'about:blank';
|
||||
if (needsOpener) {
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: this.browserWebPreferences(),
|
||||
},
|
||||
};
|
||||
}
|
||||
void this.newTab(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
|
||||
void shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
|
||||
private wirePopupWindow(child: BrowserWindow): void {
|
||||
this.popupWindows.add(child);
|
||||
child.once('closed', () => this.popupWindows.delete(child));
|
||||
this.wireWindowPolicy(child.webContents);
|
||||
}
|
||||
|
||||
/** True if `win` is an OAuth/SSO popup created by page window.open(). */
|
||||
isPopupWindow(win: BrowserWindow): boolean {
|
||||
return this.popupWindows.has(win);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP basic/proxy auth. Chromium's default is to cancel the challenge, so
|
||||
* 401-protected sites and authenticating proxies dead-end. When the browser
|
||||
* pane is on screen to answer, forward the challenge to it as a credential
|
||||
* prompt (cancelled after a timeout if unanswered). When the pane is closed
|
||||
* — e.g. agent-driven navigation — don't preventDefault, so Chromium cancels
|
||||
* immediately and the 401 page is readable rather than hanging.
|
||||
*/
|
||||
private wireHttpAuth(wc: WebContents): void {
|
||||
wc.on('login', (event, _details, authInfo, callback) => {
|
||||
if (!this.visible || !this.window) return;
|
||||
event.preventDefault();
|
||||
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
this.finishHttpAuth(requestId);
|
||||
}, HTTP_AUTH_TIMEOUT_MS);
|
||||
this.pendingHttpAuth.set(requestId, { callback, timer, webContents: wc });
|
||||
// If the challenging contents dies before an answer, resolve now so the
|
||||
// native callback and timer don't leak (backstop for paths other than
|
||||
// destroyTab, which cancels explicitly before removeAllListeners()).
|
||||
wc.once('destroyed', () => this.finishHttpAuth(requestId));
|
||||
|
||||
const request: HttpAuthRequest = {
|
||||
requestId,
|
||||
host: authInfo.host,
|
||||
isProxy: authInfo.isProxy,
|
||||
...(authInfo.realm ? { realm: authInfo.realm } : {}),
|
||||
};
|
||||
this.emit('http-auth-request', request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending auth challenge. `username === undefined` cancels it; an
|
||||
* empty-string username is a valid submission (token-style Basic auth).
|
||||
* Always notifies the renderer so a dialog it may still be showing (e.g.
|
||||
* after a timeout or tab close) is pruned.
|
||||
*/
|
||||
private finishHttpAuth(requestId: string, username?: string, password?: string): boolean {
|
||||
const pending = this.pendingHttpAuth.get(requestId);
|
||||
if (!pending) return false;
|
||||
this.pendingHttpAuth.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
try {
|
||||
if (username == null) {
|
||||
pending.callback();
|
||||
} else {
|
||||
pending.callback(username, password ?? '');
|
||||
}
|
||||
} catch {
|
||||
// The challenged webContents may already be destroyed.
|
||||
}
|
||||
this.emit('http-auth-resolved', requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelHttpAuthForWebContents(wc: WebContents): void {
|
||||
const ids: string[] = [];
|
||||
for (const [requestId, pending] of this.pendingHttpAuth) {
|
||||
if (pending.webContents === wc) ids.push(requestId);
|
||||
}
|
||||
for (const requestId of ids) {
|
||||
this.finishHttpAuth(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
respondToHttpAuth(input: {
|
||||
requestId: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}): { ok: boolean } {
|
||||
return { ok: this.finishHttpAuth(input.requestId, input.username, input.password) };
|
||||
}
|
||||
|
||||
private snapshotTabState(tab: BrowserTab): BrowserTabState {
|
||||
const wc = tab.view.webContents;
|
||||
return {
|
||||
|
|
@ -364,6 +583,10 @@ export class BrowserViewManager extends EventEmitter {
|
|||
|
||||
private destroyTab(tab: BrowserTab): void {
|
||||
this.invalidateSnapshot(tab.id);
|
||||
// Cancel any auth challenge this tab raised before we drop its listeners,
|
||||
// so the native callback + timer don't leak and the renderer prunes its
|
||||
// dialog (removeAllListeners() below would kill the 'destroyed' backstop).
|
||||
this.cancelHttpAuthForWebContents(tab.view.webContents);
|
||||
tab.view.webContents.removeAllListeners();
|
||||
if (!tab.view.webContents.isDestroyed()) {
|
||||
tab.view.webContents.close();
|
||||
|
|
|
|||
|
|
@ -315,3 +315,50 @@ export async function listToolkits() {
|
|||
totalItems: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Composio tool by slug on behalf of a Mini App. The toolkit must be
|
||||
* connected (ACTIVE). Mirrors the agent's composio-execute-tool builtin.
|
||||
*/
|
||||
export async function executeTool(
|
||||
toolkitSlug: string,
|
||||
toolSlug: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<{ successful: boolean; data?: unknown; error?: string }> {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { successful: false, error: `Toolkit "${toolkitSlug}" is not connected.` };
|
||||
}
|
||||
try {
|
||||
const result = await composioClient.executeAction(toolSlug, {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: args ?? {},
|
||||
});
|
||||
return { successful: result.successful, data: result.data, error: result.error ?? undefined };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Composio] Mini App tool execution failed for ${toolSlug}:`, message);
|
||||
return { successful: false, error: `Failed to execute ${toolSlug}: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Composio tools within a toolkit so a Mini App can discover the right
|
||||
* tool slug + input schema at runtime (how generated apps will wire actions).
|
||||
*/
|
||||
export async function searchToolsInToolkit(
|
||||
toolkitSlug: string,
|
||||
query: string,
|
||||
): Promise<{ tools: Array<{ slug: string; name: string; description?: string }>; error?: string }> {
|
||||
try {
|
||||
const { items } = await composioClient.searchTools(query, [toolkitSlug]);
|
||||
return {
|
||||
tools: items.map((t) => ({ slug: t.slug, name: t.name, description: t.description })),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { tools: [], error: message };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null
|
|||
export function dispatchUrl(url: string): void {
|
||||
if (parseAction(url)) {
|
||||
void dispatchAction(url);
|
||||
} else if (parsePickerCompletion(url)) {
|
||||
void dispatchPickerCompletion(url);
|
||||
} else if (parseOAuthCompletion(url)) {
|
||||
void dispatchOAuthCompletion(url);
|
||||
} else {
|
||||
|
|
@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise<void> {
|
|||
await completeRowboatGoogleConnect(parsed.state);
|
||||
}
|
||||
|
||||
// --- Managed OAuth-redirect Picker completion ---
|
||||
|
||||
interface PickerCompletion {
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match rowboat://oauth/google/picker/done?session=<state>. Distinct from the
|
||||
* connect completion above (oauth/google/done) by the extra `picker` segment.
|
||||
*/
|
||||
function parsePickerCompletion(url: string): PickerCompletion | null {
|
||||
if (!url.startsWith(URL_PREFIX)) return null;
|
||||
const rest = url.slice(URL_PREFIX.length);
|
||||
const queryIdx = rest.indexOf("?");
|
||||
const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length !== 4) return null;
|
||||
if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null;
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||
const state = params.get("session");
|
||||
return state ? { state } : null;
|
||||
}
|
||||
|
||||
async function dispatchPickerCompletion(url: string): Promise<void> {
|
||||
const parsed = parsePickerCompletion(url);
|
||||
if (!parsed) return;
|
||||
|
||||
const win = mainWindowRef;
|
||||
if (win && !win.isDestroyed()) focusWindow(win);
|
||||
|
||||
// Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps.
|
||||
const { completeManagedGooglePick } = await import("./google-picker-managed.js");
|
||||
await completeManagedGooglePick(parsed.state);
|
||||
}
|
||||
|
||||
function focusWindow(win: BrowserWindow): void {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
|
|
|
|||
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { shell, BrowserWindow } from 'electron';
|
||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js';
|
||||
import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js';
|
||||
|
||||
// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on
|
||||
// the Rowboat backend with the COMPANY Google client — the desktop never holds
|
||||
// a client_id/secret or an API key. The desktop just opens the start URL, waits
|
||||
// for the deep link, claims the picked file ids, and downloads them with the
|
||||
// user's EXISTING managed Google token (which already holds drive.file from the
|
||||
// main connect). No Picker API key, appId, ngrok, or local OAuth.
|
||||
//
|
||||
// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing
|
||||
// managed Google-connect (start URL → park under session → deep-link back):
|
||||
//
|
||||
// GET ${webappUrl}/oauth/google/picker/start
|
||||
// Runs Google OAuth with the company client, scope=drive.file ONLY,
|
||||
// trigger_onepick=true, prompt=consent. Tied to the logged-in web
|
||||
// session (cookies), exactly like /oauth/google/start.
|
||||
//
|
||||
// GET ${webappUrl}/oauth/google/picker/callback
|
||||
// Google returns `picked_file_ids` (+ code). Park the ids under a
|
||||
// one-shot `session` ticket, then deep-link the desktop:
|
||||
// rowboat://oauth/google/picker/done?session=<state>
|
||||
// (No need to exchange the code: the file is granted to the company
|
||||
// client, so the desktop's existing managed token can read it.)
|
||||
//
|
||||
// POST ${API_URL}/v1/google-oauth/claim-picked body { session }
|
||||
// Authenticated with the user's Rowboat bearer. Returns
|
||||
// { fileIds: string[], tokens: { access_token, ... } } — a fresh
|
||||
// drive.file token minted during the picker's own authorization.
|
||||
|
||||
export interface ManagedPickResult {
|
||||
path: string;
|
||||
doc: GoogleDocListItem;
|
||||
}
|
||||
|
||||
interface PendingPick {
|
||||
targetFolder: string;
|
||||
resolve: (result: ManagedPickResult | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep
|
||||
// link can't carry our targetFolder, so we stash it here for completion.
|
||||
let pending: PendingPick | null = null;
|
||||
const TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function clearPending(): void {
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function focusApp(): void {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the managed picker in the browser and resolve once the deep link comes
|
||||
* back with the user's selection (or null on cancel/timeout). The actual import
|
||||
* happens in completeManagedGooglePick, fired by the deep-link dispatcher.
|
||||
*/
|
||||
export async function startManagedGooglePick(targetFolder: string): Promise<ManagedPickResult | null> {
|
||||
// Supersede any abandoned flow so a stale deep link can't resolve this one.
|
||||
if (pending) {
|
||||
const stale = pending;
|
||||
clearPending();
|
||||
stale.resolve(null);
|
||||
}
|
||||
|
||||
const webappUrl = await getWebappUrl();
|
||||
return await new Promise<ManagedPickResult | null>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (pending) {
|
||||
clearPending();
|
||||
resolve(null);
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
pending = { targetFolder, resolve, reject, timer };
|
||||
void shell.openExternal(`${webappUrl}/oauth/google/picker/start`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link handler for rowboat://oauth/google/picker/done?session=<state>.
|
||||
* Claims the picked file ids from the backend and imports the first one with
|
||||
* the existing managed token, resolving the promise startManagedGooglePick
|
||||
* returned.
|
||||
*/
|
||||
export async function completeManagedGooglePick(session: string): Promise<void> {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)');
|
||||
return;
|
||||
}
|
||||
clearPending();
|
||||
focusApp();
|
||||
|
||||
try {
|
||||
const { fileIds, accessToken } = await claimPickedFilesViaBackend(session);
|
||||
if (fileIds.length === 0 || !accessToken) {
|
||||
current.resolve(null);
|
||||
return;
|
||||
}
|
||||
// Download with the picker's own fresh drive.file token (the main
|
||||
// connection doesn't carry drive.file).
|
||||
const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken);
|
||||
current.resolve(result);
|
||||
} catch (error) {
|
||||
current.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,11 @@
|
|||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
|
||||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
|
||||
import path from "node:path";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startRunsWatcher, startSessionsWatcher, markSessionsIndexReady,
|
||||
startCodeRunFeedWatcher,
|
||||
startChannelsWatcher,
|
||||
startCodeSessionStatusWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
startBackgroundTaskAgentWatcher,
|
||||
|
|
@ -11,6 +14,7 @@ import {
|
|||
stopServicesWatcher,
|
||||
stopWorkspaceWatcher
|
||||
} from "./ipc.js";
|
||||
import { disposeAllTerminals } from "./terminal.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
|
|
@ -23,25 +27,32 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j
|
|||
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initChannels } from "@x/core/dist/channels/service.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
|
||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
||||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js";
|
||||
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
|
||||
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
|
||||
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import type { ISessions } from "@x/core/dist/sessions/index.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -54,7 +65,10 @@ import {
|
|||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
// Captured as early as possible so it reflects actual process start. Used to
|
||||
// gate grace-eligible notifications (e.g. the burst of background-task
|
||||
// completions a reopen replays) — see ElectronNotificationService.
|
||||
const APP_LAUNCHED_AT = Date.now();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -211,6 +225,38 @@ function configureSessionPermissions(targetSession: Session): void {
|
|||
});
|
||||
}
|
||||
|
||||
// Wire Ctrl/Cmd + (+ / − / 0) to zoom the renderer in/out/reset.
|
||||
// The app sets no application menu, so the default menu's zoom roles aren't
|
||||
// available — and on Linux the menu bar is suppressed by the frameless
|
||||
// `hiddenInset` title bar — so handle the accelerators directly here.
|
||||
// `event.preventDefault()` stops the keystroke from leaking into the editor.
|
||||
function setupZoomShortcuts(win: BrowserWindow) {
|
||||
const ZOOM_STEP = 0.5; // zoom-level units (factor = 1.2 ^ level, ~9.5% per step)
|
||||
const MIN_ZOOM_LEVEL = -3;
|
||||
const MAX_ZOOM_LEVEL = 3;
|
||||
const wc = win.webContents;
|
||||
|
||||
wc.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
// Cmd on macOS, Ctrl elsewhere.
|
||||
if (!(process.platform === "darwin" ? input.meta : input.control)) return;
|
||||
|
||||
// input.key is the produced character: "+"/"=" share a physical key (as do
|
||||
// "-"/"_"), and numpad +/- produce the same characters, so this covers both.
|
||||
const key = input.key;
|
||||
if (key === "+" || key === "=") {
|
||||
wc.setZoomLevel(Math.min(wc.getZoomLevel() + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
event.preventDefault();
|
||||
} else if (key === "-" || key === "_") {
|
||||
wc.setZoomLevel(Math.max(wc.getZoomLevel() - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
event.preventDefault();
|
||||
} else if (key === "0") {
|
||||
wc.setZoomLevel(0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
|
|
@ -253,20 +299,43 @@ function createWindow() {
|
|||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank").
|
||||
// Returns true when the URL was external and routed to the system browser.
|
||||
const routeExternalNavigation = (url: string): boolean => {
|
||||
const isInternal =
|
||||
url.startsWith("app://") || url.startsWith("http://localhost:5173");
|
||||
if (!isInternal) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
if (isInternal) return false;
|
||||
shell.openExternal(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
if (routeExternalNavigation(url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Subframe navigations (e.g. links clicked inside the sandboxed iframe that
|
||||
// renders a background-task / workspace `index.html`) fire `will-frame-navigate`,
|
||||
// not `will-navigate`. Route their external links to the system browser too,
|
||||
// so HTML reports behave like the markdown viewer. Main-frame navigations are
|
||||
// already handled by `will-navigate` above — skip them here to avoid double-open.
|
||||
//
|
||||
// Scope this to our own HTML viewer frames (identified by their app://workspace
|
||||
// document origin). Third-party note embeds (YouTube, Figma, Twitter via the
|
||||
// embed/iframe blocks) load from their own origins — leave their internal
|
||||
// navigation untouched so the embeds keep working.
|
||||
win.webContents.on("will-frame-navigate", (event) => {
|
||||
if (event.isMainFrame) return;
|
||||
if (!event.frame?.url.startsWith("app://workspace/")) return;
|
||||
if (routeExternalNavigation(event.url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Attach the embedded browser pane manager to this window.
|
||||
// The WebContentsView is created lazily on first `browser:setVisible`.
|
||||
browserViewManager.attach(win);
|
||||
|
||||
// Cmd/Ctrl + (+ / − / 0) zoom shortcuts for the renderer UI.
|
||||
setupZoomShortcuts(win);
|
||||
|
||||
if (app.isPackaged) {
|
||||
win.loadURL("app://-/index.html");
|
||||
} else {
|
||||
|
|
@ -274,6 +343,16 @@ function createWindow() {
|
|||
}
|
||||
}
|
||||
|
||||
// Renderer/child process deaths are otherwise silent in packaged builds (the
|
||||
// window or an app iframe just goes blank). Log the reason so crash reports
|
||||
// can be correlated with what Chromium thought happened.
|
||||
app.on('render-process-gone', (_event, webContents, details) => {
|
||||
console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`);
|
||||
});
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register custom protocol before creating window.
|
||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||
|
|
@ -291,18 +370,13 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Ensure agent-slack CLI is available
|
||||
try {
|
||||
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
|
||||
} catch {
|
||||
try {
|
||||
console.log('agent-slack not found, installing...');
|
||||
await execAsync('npm install -g agent-slack', { timeout: 60000 });
|
||||
console.log('agent-slack installed successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to install agent-slack:', e);
|
||||
}
|
||||
}
|
||||
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
|
||||
// and is resolved per call by the shared executor in @x/core. Availability is
|
||||
// exposed to the UI via the slack:cliStatus IPC channel; this startup log is
|
||||
// diagnostics only.
|
||||
getAgentSlackCliStatus().then((status) => {
|
||||
console.log('[Slack] agent-slack CLI status:', status);
|
||||
}).catch(() => { /* probe failures already surface through slack:cliStatus */ });
|
||||
|
||||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
|
@ -315,11 +389,29 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
||||
// Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
|
||||
// the window and the long service-init chain below. The Apps view is
|
||||
// reachable as soon as the window paints; starting the server last meant
|
||||
// every app iframe hit connection-refused (blank app) for the first ~10s of
|
||||
// each launch. Route registration and the token cipher are synchronous;
|
||||
// the listen itself is fire-and-forget.
|
||||
registerAppsHostApi();
|
||||
// GitHub publish token at rest: encrypt via the OS keychain when available
|
||||
// (core stays electron-free; the cipher is injected here).
|
||||
setGithubTokenCipher({
|
||||
isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
|
||||
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
|
||||
});
|
||||
initAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to start:', error);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
// Start workspace watcher as a main-process service
|
||||
|
|
@ -332,6 +424,48 @@ app.whenReady().then(async () => {
|
|||
// start runs watcher
|
||||
startRunsWatcher();
|
||||
|
||||
// One-time: port legacy runs/*.jsonl into the new turn/session runtime.
|
||||
// Must run BEFORE the session index is built so migrated sessions are picked
|
||||
// up by the startup scan. Fully defensive — never blocks boot.
|
||||
try {
|
||||
const migration = migrateRuns();
|
||||
if (migration.scanned > 0) {
|
||||
console.log(
|
||||
`[runs-migration] migrated ${migration.migratedTurns} turn(s) across ` +
|
||||
`${migration.migratedSessions} session(s) from ${migration.scanned} run(s) ` +
|
||||
`(${migration.skipped} skipped, ${migration.failed.length} failed)`,
|
||||
);
|
||||
for (const failure of migration.failed) {
|
||||
console.warn(`[runs-migration] left in place (failed): ${failure.file} — ${failure.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[runs-migration] pass failed:', error);
|
||||
}
|
||||
|
||||
// New runtime: build the in-memory session index (startup scan), then
|
||||
// forward the session bus to windows. The renderer window is already up and
|
||||
// may have called sessions:list — that handler blocks on
|
||||
// markSessionsIndexReady, which must fire even if the scan throws so the
|
||||
// list never hangs.
|
||||
try {
|
||||
await container.resolve<ISessions>('sessions').initialize();
|
||||
} finally {
|
||||
markSessionsIndexReady();
|
||||
}
|
||||
startSessionsWatcher();
|
||||
startCodeRunFeedWatcher();
|
||||
|
||||
// Mobile channels (WhatsApp/Telegram bridge): needs the session index, so
|
||||
// start after initialize(). Failures must never block boot.
|
||||
startChannelsWatcher();
|
||||
initChannels().catch((error) => {
|
||||
console.error('[Channels] Failed to start mobile channels:', error);
|
||||
});
|
||||
|
||||
// start code-session status tracker (derives working/needs-you/idle + notifications)
|
||||
startCodeSessionStatusWatcher();
|
||||
|
||||
// start services watcher
|
||||
startServicesWatcher();
|
||||
|
||||
|
|
@ -347,6 +481,10 @@ app.whenReady().then(async () => {
|
|||
// start bg-task scheduler (cron / window)
|
||||
initBackgroundTaskScheduler();
|
||||
|
||||
// start disk-skills watcher: live-reload skills dropped into
|
||||
// ~/.rowboat/skills or ~/.agents/skills without an app restart
|
||||
startSkillsWatcher();
|
||||
|
||||
// register event consumers and start the shared event processor
|
||||
// (consumes $WorkDir/events/pending/, routes events to all consumers
|
||||
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
|
||||
|
|
@ -392,14 +530,12 @@ app.whenReady().then(async () => {
|
|||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start meeting prep scheduler (generates prep notes ~6h before a meeting)
|
||||
void initMeetingPrep();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
// start local sites server for iframe dashboards and other mini apps
|
||||
initLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to start:', error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
@ -418,14 +554,17 @@ app.on("before-quit", () => {
|
|||
stopWorkspaceWatcher();
|
||||
stopRunsWatcher();
|
||||
stopServicesWatcher();
|
||||
stopSkillsWatcher();
|
||||
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
||||
try {
|
||||
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
||||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
// Kill embedded terminal shells.
|
||||
disposeAllTerminals();
|
||||
shutdownAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to shut down cleanly:', error);
|
||||
});
|
||||
shutdownAnalytics().catch((error) => {
|
||||
console.error('[Analytics] Failed to flush on quit:', error);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserWindow, Notification, shell } from "electron";
|
||||
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
|
||||
import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js";
|
||||
import { dispatchUrl } from "../deeplink.js";
|
||||
|
||||
const HTTP_URL = /^https?:\/\//i;
|
||||
|
|
@ -11,11 +12,35 @@ export class ElectronNotificationService implements INotificationService {
|
|||
// gets dropped and macOS clicks just focus the app silently.
|
||||
private active = new Set<Notification>();
|
||||
|
||||
// Timestamp the app launched, used to gate grace-eligible notifications (see
|
||||
// NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts
|
||||
// so it reflects process start rather than whenever the first notify fires.
|
||||
private readonly launchedAt: number;
|
||||
|
||||
constructor(launchedAt: number = Date.now()) {
|
||||
this.launchedAt = launchedAt;
|
||||
}
|
||||
|
||||
isSupported(): boolean {
|
||||
return Notification.isSupported();
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void {
|
||||
// Startup grace: a reopen replays every background task that completed
|
||||
// while the app was closed, so grace-eligible notifications fired in the
|
||||
// first moments after launch are dropped to avoid a notification flood.
|
||||
if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ambient notifications are suppressed while the app is in the
|
||||
// foreground — the user is already looking at it. A window counts as
|
||||
// foreground only if it's actually focused (minimized / other-space
|
||||
// windows are not), so this correctly treats those as background.
|
||||
if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the actions array AND a parallel index → link map.
|
||||
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
||||
// additional ones live behind the chevron menu.
|
||||
|
|
|
|||
|
|
@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
signedInUserId = billing.userId;
|
||||
analyticsIdentify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
plan: billing.subscriptionPlanId,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
analyticsCapture('user_signed_in', {
|
||||
plan: billing.subscriptionPlan,
|
||||
plan: billing.subscriptionPlanId,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
}
|
||||
|
|
@ -420,15 +420,23 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
// Google only returns a refresh_token when offline access is requested,
|
||||
// and only re-issues one when re-consent is forced. Without these, a
|
||||
// BYOK token expires after ~1h with no way to refresh (it goes stale and
|
||||
// every Google call — including the Picker — starts failing).
|
||||
...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}),
|
||||
});
|
||||
|
||||
// Set timeout to clean up abandoned flows (2 minutes)
|
||||
// Set timeout to clean up abandoned flows. Generous (10 min) because a
|
||||
// first-time connect can involve creating/locating OAuth credentials in
|
||||
// the Cloud Console mid-flow; a short window tears down the callback
|
||||
// server before the user finishes consent, silently dropping the token.
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlow?.state === state) {
|
||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||
cancelActiveFlow('timed_out');
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
activeFlow = {
|
||||
provider,
|
||||
|
|
|
|||
126
apps/x/apps/main/src/terminal.ts
Normal file
126
apps/x/apps/main/src/terminal.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
// node-pty is a NATIVE module: it stays external to the esbuild bundle and is
|
||||
// shipped alongside it in .package/node_modules (see bundle.mjs).
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
// One PTY per coding session, kept alive while the app runs so the terminal
|
||||
// survives pane collapses and session switches. The renderer view re-attaches
|
||||
// via `terminal:ensure`, which replays the recent backlog.
|
||||
|
||||
const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay
|
||||
|
||||
interface TerminalEntry {
|
||||
proc: pty.IPty;
|
||||
cwd: string;
|
||||
backlog: string;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const terminals = new Map<string, TerminalEntry>();
|
||||
|
||||
function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable
|
||||
// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once.
|
||||
let helperFixed = false;
|
||||
function ensureSpawnHelperExecutable(): void {
|
||||
if (helperFixed || process.platform === 'win32') return;
|
||||
helperFixed = true;
|
||||
try {
|
||||
const pkgDir = path.dirname(require.resolve('node-pty/package.json'));
|
||||
const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) {
|
||||
fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
} catch {
|
||||
// best effort — spawn() will surface a real error if this mattered
|
||||
}
|
||||
}
|
||||
|
||||
function defaultShell(): { file: string; args: string[] } {
|
||||
if (process.platform === 'win32') {
|
||||
return { file: 'powershell.exe', args: [] };
|
||||
}
|
||||
// Login shell so the user's PATH/aliases match their normal terminal.
|
||||
return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] };
|
||||
}
|
||||
|
||||
function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry {
|
||||
ensureSpawnHelperExecutable();
|
||||
const { file, args } = defaultShell();
|
||||
const proc = pty.spawn(file, args, {
|
||||
name: 'xterm-256color',
|
||||
cwd,
|
||||
cols,
|
||||
rows,
|
||||
env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record<string, string>,
|
||||
});
|
||||
const entry: TerminalEntry = { proc, cwd, backlog: '', running: true };
|
||||
proc.onData((data) => {
|
||||
entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT);
|
||||
broadcast('terminal:data', { id, data });
|
||||
});
|
||||
proc.onExit(({ exitCode }) => {
|
||||
entry.running = false;
|
||||
broadcast('terminal:exit', { id, exitCode });
|
||||
});
|
||||
terminals.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or
|
||||
// an exited shell gets a fresh PTY; otherwise the live one is reused and the
|
||||
// caller repaints from the backlog.
|
||||
export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } {
|
||||
const existing = terminals.get(id);
|
||||
if (existing && existing.running && existing.cwd === cwd) {
|
||||
existing.proc.resize(cols, rows);
|
||||
return { backlog: existing.backlog, running: true };
|
||||
}
|
||||
if (existing) {
|
||||
disposeTerminal(id);
|
||||
}
|
||||
const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir();
|
||||
const entry = spawnEntry(id, fallbackCwd, cols, rows);
|
||||
return { backlog: entry.backlog, running: entry.running };
|
||||
}
|
||||
|
||||
export function writeTerminal(id: string, data: string): void {
|
||||
const entry = terminals.get(id);
|
||||
if (entry?.running) entry.proc.write(data);
|
||||
}
|
||||
|
||||
export function resizeTerminal(id: string, cols: number, rows: number): void {
|
||||
const entry = terminals.get(id);
|
||||
if (entry?.running) {
|
||||
try {
|
||||
entry.proc.resize(cols, rows);
|
||||
} catch {
|
||||
// resizing a dying pty throws — harmless
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeTerminal(id: string): void {
|
||||
const entry = terminals.get(id);
|
||||
if (!entry) return;
|
||||
terminals.delete(id);
|
||||
try {
|
||||
entry.proc.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeAllTerminals(): void {
|
||||
for (const id of [...terminals.keys()]) disposeTerminal(id);
|
||||
}
|
||||
|
|
@ -6,10 +6,18 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@eigenpal/docx-editor-react": "^1.0.3",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
|
@ -38,10 +46,13 @@
|
|||
"@tiptap/starter-kit": "3.22.4",
|
||||
"@x/preload": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"ai": "^5.0.117",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"motion": "^12.23.26",
|
||||
|
|
@ -74,6 +85,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -82,9 +96,11 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
apps/x/apps/renderer/scripts/generate-tour-audio.mjs
Normal file
62
apps/x/apps/renderer/scripts/generate-tour-audio.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Regenerates the bundled product-tour narration clips.
|
||||
*
|
||||
* Parses the TOUR_STEPS texts straight out of product-tour.tsx (so the code
|
||||
* stays the single source of truth), synthesizes each one via @x/core's
|
||||
* synthesizeSpeech (Rowboat proxy when signed in, direct ElevenLabs otherwise,
|
||||
* using the voice id configured there / in ~/.rowboat/config/elevenlabs.json),
|
||||
* and writes MP3s to src/assets/tour/<step-id>.mp3.
|
||||
*
|
||||
* Run whenever a step's narration text or the tour voice changes:
|
||||
* cd apps/x && npm run deps # script imports core's built output
|
||||
* node apps/renderer/scripts/generate-tour-audio.mjs
|
||||
*
|
||||
* Pass step ids to regenerate only those clips (e.g. to re-roll one whose
|
||||
* synthesis came out glitchy):
|
||||
* node apps/renderer/scripts/generate-tour-audio.mjs welcome done
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const tourSource = path.join(here, '../src/components/product-tour.tsx')
|
||||
const outDir = path.join(here, '../src/assets/tour')
|
||||
const corePath = path.join(here, '../../../packages/core/dist/voice/voice.js')
|
||||
|
||||
const { synthesizeSpeech } = await import(corePath)
|
||||
|
||||
const src = await readFile(tourSource, 'utf8')
|
||||
const start = src.indexOf('const TOUR_STEPS')
|
||||
const end = src.indexOf('\n]', start)
|
||||
if (start === -1 || end === -1) throw new Error('Could not locate TOUR_STEPS in product-tour.tsx')
|
||||
const block = src.slice(start, end)
|
||||
|
||||
const steps = []
|
||||
// voiceText, when present, is the spoken variant of the bubble text.
|
||||
const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')(?:,\s*voiceText:\s*("[^"]*"|'[^']*'))?/g
|
||||
for (let m; (m = re.exec(block)); ) {
|
||||
// The captures are JS string literals from our own source; evaluate them
|
||||
// to resolve the quoting.
|
||||
steps.push({ id: m[1], text: new Function(`return ${m[3] ?? m[2]}`)() })
|
||||
}
|
||||
if (steps.length === 0) throw new Error('Parsed zero tour steps — regex out of sync with product-tour.tsx?')
|
||||
console.log(`Parsed ${steps.length} tour steps`)
|
||||
|
||||
const only = process.argv.slice(2)
|
||||
if (only.length > 0) {
|
||||
const unknown = only.filter((id) => !steps.some((s) => s.id === id))
|
||||
if (unknown.length > 0) throw new Error(`Unknown step ids: ${unknown.join(', ')}`)
|
||||
steps.splice(0, steps.length, ...steps.filter((s) => only.includes(s.id)))
|
||||
console.log(`Regenerating only: ${only.join(', ')}`)
|
||||
}
|
||||
|
||||
await mkdir(outDir, { recursive: true })
|
||||
for (const step of steps) {
|
||||
process.stdout.write(`synthesizing ${step.id}... `)
|
||||
const { audioBase64 } = await synthesizeSpeech(step.text)
|
||||
const file = path.join(outDir, `${step.id}.mp3`)
|
||||
await writeFile(file, Buffer.from(audioBase64, 'base64'))
|
||||
console.log(`${(audioBase64.length * 0.75 / 1024).toFixed(0)} KB`)
|
||||
}
|
||||
console.log(`Done — ${steps.length} clips in ${path.relative(process.cwd(), outDir)}`)
|
||||
|
|
@ -82,64 +82,40 @@
|
|||
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Inbox surface — mapped onto the app's design tokens so it follows the same
|
||||
palette, accent, and theme switching as the rest of Rowboat. Light/dark is
|
||||
handled by the app tokens (:root / .dark), so no separate .light override. */
|
||||
.gmail-shell {
|
||||
--gm-bg: #0f0f12;
|
||||
--gm-bg-card: #131317;
|
||||
--gm-bg-input: #1c1c20;
|
||||
--gm-bg-row-hover: #16161a;
|
||||
--gm-bg-row-selected: #1a1620;
|
||||
--gm-bg-row-selected-hover: #1d1825;
|
||||
--gm-bg-iframe: #fafafa;
|
||||
--gm-bg-pill: #1c1c20;
|
||||
--gm-bg-pill-hover: #232328;
|
||||
--gm-text: #e4e4e7;
|
||||
--gm-text-strong: #fafafa;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-faint: #52525b;
|
||||
--gm-text-body: #d4d4d8;
|
||||
--gm-border: #1f1f24;
|
||||
--gm-border-strong: #2e2e35;
|
||||
--gm-accent: #a78bfa;
|
||||
--gm-accent-hover: #b9a6ff;
|
||||
--gm-accent-glow: rgba(167, 139, 250, 0.45);
|
||||
--gm-accent-fg: #18181b;
|
||||
--gm-icon-hover-bg: #1f1f24;
|
||||
--gm-placeholder: #52525b;
|
||||
--gm-bg: var(--background);
|
||||
--gm-bg-card: var(--card-surface);
|
||||
--gm-bg-input: var(--muted);
|
||||
--gm-bg-row-hover: var(--accent);
|
||||
--gm-bg-row-selected: var(--rowboat-wash);
|
||||
--gm-bg-row-selected-hover: color-mix(in oklab, var(--primary) 18%, var(--background));
|
||||
--gm-bg-iframe: #ffffff;
|
||||
--gm-bg-pill: var(--background);
|
||||
--gm-bg-pill-hover: var(--accent);
|
||||
--gm-text: var(--foreground);
|
||||
--gm-text-strong: var(--foreground);
|
||||
--gm-text-muted: var(--muted-foreground);
|
||||
--gm-text-faint: color-mix(in oklab, var(--muted-foreground) 70%, var(--background));
|
||||
--gm-text-body: color-mix(in oklab, var(--foreground) 88%, var(--background));
|
||||
--gm-border: var(--border);
|
||||
--gm-border-strong: var(--rowboat-hairline);
|
||||
--gm-accent: var(--primary);
|
||||
--gm-accent-hover: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
--gm-accent-glow: color-mix(in oklab, var(--primary) 40%, transparent);
|
||||
--gm-accent-fg: var(--primary-foreground);
|
||||
--gm-icon-hover-bg: var(--accent);
|
||||
--gm-placeholder: var(--muted-foreground);
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg);
|
||||
color: var(--gm-text);
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
.light .gmail-shell {
|
||||
--gm-bg: #ffffff;
|
||||
--gm-bg-card: #ffffff;
|
||||
--gm-bg-input: #f4f4f7;
|
||||
--gm-bg-row-hover: #f7f7f9;
|
||||
--gm-bg-row-selected: #f5f0ff;
|
||||
--gm-bg-row-selected-hover: #ece4ff;
|
||||
--gm-bg-iframe: #ffffff;
|
||||
--gm-bg-pill: #ffffff;
|
||||
--gm-bg-pill-hover: #f4f4f7;
|
||||
--gm-text: #27272a;
|
||||
--gm-text-strong: #09090b;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-faint: #a1a1aa;
|
||||
--gm-text-body: #3f3f46;
|
||||
--gm-border: #e4e4e7;
|
||||
--gm-border-strong: #d4d4d8;
|
||||
--gm-accent: #7c3aed;
|
||||
--gm-accent-hover: #6d28d9;
|
||||
--gm-accent-glow: rgba(124, 58, 237, 0.3);
|
||||
--gm-accent-fg: #ffffff;
|
||||
--gm-icon-hover-bg: #f4f4f7;
|
||||
--gm-placeholder: #a1a1aa;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.gmail-main {
|
||||
|
|
@ -160,6 +136,13 @@
|
|||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gmail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -186,6 +169,25 @@
|
|||
color: var(--gm-placeholder);
|
||||
}
|
||||
|
||||
.gmail-search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-search-clear:hover {
|
||||
background: var(--gm-icon-hover-bg);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -222,6 +224,46 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Native list virtualization: offscreen rows skip layout and paint entirely.
|
||||
Applied only to rows without a mounted ThreadDetail (those hold iframes and
|
||||
composers, which must keep rendering while offscreen). */
|
||||
.gmail-row-group-cv {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 40px;
|
||||
}
|
||||
|
||||
/* While the list is scrolling, rows ignore the pointer so hover restyles and
|
||||
prefetch timers don't compete with frame rendering. */
|
||||
.gmail-shell[data-scrolling] .gmail-row-shell {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Archived/trashed rows slide out and collapse before removal. Removing the
|
||||
class (failed action) snaps the row back. */
|
||||
.gmail-row-group-leaving {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
animation: gmail-row-leave 160ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes gmail-row-leave {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
max-height: 48px;
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(32px);
|
||||
max-height: 48px;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(32px);
|
||||
max-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.gmail-list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -288,6 +330,11 @@
|
|||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
/* The overlay may extend over the subject text; a backdrop matching the
|
||||
row's current background (see --gm-row-actions-bg below) plus a soft left
|
||||
fade keeps it readable instead of colliding. */
|
||||
padding-left: 18px;
|
||||
background: linear-gradient(to right, transparent, var(--gm-row-actions-bg, var(--gm-bg-row-hover)) 18px);
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover .gmail-row-actions {
|
||||
|
|
@ -295,6 +342,19 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Backdrop color tracks the row state underneath the overlay. */
|
||||
.gmail-row-shell:hover {
|
||||
--gm-row-actions-bg: var(--gm-bg-row-hover);
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover:has(.gmail-row-selected) {
|
||||
--gm-row-actions-bg: var(--gm-bg-row-selected-hover);
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover:has(.gmail-row-selected.gmail-row-focused) {
|
||||
--gm-row-actions-bg: var(--gm-bg-row-selected);
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover .gmail-row-date {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
|
@ -319,10 +379,10 @@
|
|||
}
|
||||
|
||||
.gmail-row-action-danger:hover {
|
||||
color: #e8453c;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.gmail-row:hover {
|
||||
.gmail-row-shell:hover .gmail-row {
|
||||
background: var(--gm-bg-row-hover);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
@ -332,10 +392,24 @@
|
|||
box-shadow: inset 2px 0 0 var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-row-selected:hover {
|
||||
.gmail-row-shell:hover .gmail-row-selected {
|
||||
background: var(--gm-bg-row-selected-hover);
|
||||
}
|
||||
|
||||
/* The j/k keyboard cursor. Declared after the hover rules (same specificity)
|
||||
so the focus ring survives hovering the focused row. */
|
||||
.gmail-row-focused,
|
||||
.gmail-row-shell:hover .gmail-row-focused {
|
||||
background: var(--gm-bg-row-hover);
|
||||
box-shadow: inset 0 0 0 1px var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-row-selected.gmail-row-focused,
|
||||
.gmail-row-shell:hover .gmail-row-selected.gmail-row-focused {
|
||||
background: var(--gm-bg-row-selected);
|
||||
box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-row-unread {
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
|
@ -411,12 +485,50 @@
|
|||
border-top: 1px solid var(--gm-border);
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
box-shadow: inset 2px 0 0 var(--gm-accent);
|
||||
/* Replays whenever the detail is shown — hidden details are display: none,
|
||||
so un-hiding restarts the animation. */
|
||||
animation: gmail-detail-open 140ms ease-out;
|
||||
}
|
||||
|
||||
.gmail-detail-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes gmail-detail-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The inline reply/forward composer pops in from below the thread. */
|
||||
.gmail-compose-inline {
|
||||
animation: gmail-compose-open 140ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes gmail-compose-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gmail-detail-inline,
|
||||
.gmail-compose-inline,
|
||||
.gmail-row-group-leaving {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gmail-detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -707,411 +819,78 @@
|
|||
border-color: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-card {
|
||||
max-width: 720px;
|
||||
margin-left: 40px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg-card);
|
||||
}
|
||||
|
||||
.gmail-compose-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text-body);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-compose-header button,
|
||||
.gmail-compose-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-compose-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gmail-compose-line input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.gmail-compose-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-compose-subject-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Recipient (To / Cc / Bcc) rows with editable chips */
|
||||
.gmail-recipient-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
padding-top: 5px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-recipient-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-recipient-input {
|
||||
flex: 1 1 80px;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-trailing {
|
||||
flex: none;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button:hover {
|
||||
color: var(--gm-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--gm-border);
|
||||
background: var(--gm-bg-input);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--gm-bg-card);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover input:focus {
|
||||
border-color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover button:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover-apply {
|
||||
background: var(--gm-accent) !important;
|
||||
border-color: var(--gm-accent) !important;
|
||||
color: var(--gm-accent-fg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover-apply:hover {
|
||||
background: var(--gm-accent-hover) !important;
|
||||
border-color: var(--gm-accent-hover) !important;
|
||||
}
|
||||
|
||||
.gmail-compose-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-compose-tool:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-tool.is-active {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-tool-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 6px;
|
||||
background: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gmail-compose-content {
|
||||
/* Email composer body — Tiptap content, styled to the app's design system. */
|
||||
.compose-content {
|
||||
outline: none;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: 13px/1.55 inherit;
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gmail-compose-content p {
|
||||
.compose-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content p + p,
|
||||
.gmail-compose-content p + ul,
|
||||
.gmail-compose-content p + ol,
|
||||
.gmail-compose-content p + blockquote {
|
||||
.compose-content p + p,
|
||||
.compose-content p + ul,
|
||||
.compose-content p + ol,
|
||||
.compose-content p + blockquote {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gmail-compose-content ul,
|
||||
.gmail-compose-content ol {
|
||||
.compose-content ul,
|
||||
.compose-content ol {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.gmail-compose-content ul {
|
||||
.compose-content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.gmail-compose-content ol {
|
||||
.compose-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.gmail-compose-content li {
|
||||
.compose-content li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content li > p {
|
||||
.compose-content li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content blockquote {
|
||||
.compose-content blockquote {
|
||||
margin: 4px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--gm-border-strong);
|
||||
color: var(--gm-text-muted);
|
||||
border-left: 2px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gmail-compose-content a {
|
||||
color: var(--gm-accent);
|
||||
.compose-content a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-content code {
|
||||
.compose-content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--gm-bg-pill-hover);
|
||||
background: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gmail-compose-content p.is-editor-empty:first-child::before {
|
||||
.compose-content p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--gm-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gmail-compose-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-actions-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-refine-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-refine-button:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
border-color: var(--gm-accent);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-send-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--gm-accent);
|
||||
color: var(--gm-accent-fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-send-button:hover {
|
||||
background: var(--gm-accent-hover);
|
||||
}
|
||||
|
||||
.gmail-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1367,6 +1146,17 @@
|
|||
var(--rowboat-shadow);
|
||||
}
|
||||
|
||||
.dark .rowboat-code-chat-input {
|
||||
border-color: color-mix(in oklab, var(--border) 76%, transparent);
|
||||
background: #000000;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dark .rowboat-code-chat-input:focus-within {
|
||||
border-color: color-mix(in oklab, var(--ring) 70%, var(--border) 30%);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--ring) 30%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="message-content"] {
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
BIN
apps/x/apps/renderer/src/assets/tour/agents.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/agents.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/apps.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/apps.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/chats.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/chats.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/code.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/code.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/composer.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/composer.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/done.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/done.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/email.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/email.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/home.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/home.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/knowledge.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/knowledge.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/meetings.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/meetings.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/welcome.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/welcome.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/workspaces.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/workspaces.mp3
Normal file
Binary file not shown.
|
|
@ -43,6 +43,7 @@ export const Conversation = ({
|
|||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
const updateBottomState = useCallback(() => {
|
||||
|
|
@ -50,7 +51,9 @@ export const Conversation = ({
|
|||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX);
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
stickToBottomRef.current = atBottom;
|
||||
setIsAtBottom(atBottom);
|
||||
}, []);
|
||||
|
||||
const applyAnchorLayout = useCallback(
|
||||
|
|
@ -131,7 +134,12 @@ export const Conversation = ({
|
|||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const shouldStick = !anchorMessageId && stickToBottomRef.current;
|
||||
applyAnchorLayout(false);
|
||||
if (shouldStick) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateBottomState();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -178,6 +186,7 @@ export const Conversation = ({
|
|||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
stickToBottomRef.current = true;
|
||||
updateBottomState();
|
||||
}, [updateBottomState]);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
import { getToolActionsSummary, getToolDisplayName, getToolErrorText, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -329,7 +329,7 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
|||
<ToolTabbedContent
|
||||
input={tool.input as ToolUIPart["input"]}
|
||||
output={tool.result as ToolUIPart["output"]}
|
||||
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
||||
errorText={getToolErrorText(tool)}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
|
|
|
|||
302
apps/x/apps/renderer/src/components/apps/app-detail.tsx
Normal file
302
apps/x/apps/renderer/src/components/apps/app-detail.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||
|
||||
// App detail panel (spec §14): manifest info, provenance/publish state,
|
||||
// bundled agents with enable toggles, rollback when available. Update/publish
|
||||
// actions land with M3.
|
||||
|
||||
type AgentRow = {
|
||||
slug: string
|
||||
name: string
|
||||
active: boolean
|
||||
lastRunAt?: string
|
||||
lastRunError?: string
|
||||
}
|
||||
|
||||
export function AppDetail({ folder, onClose }: { folder: string; onClose: () => void }) {
|
||||
const [app, setApp] = useState<rowboatApp.AppSummary | null>(null)
|
||||
const [readme, setReadme] = useState<string | undefined>(undefined)
|
||||
const [rollback, setRollback] = useState(false)
|
||||
const [agents, setAgents] = useState<AgentRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null)
|
||||
const [showPublish, setShowPublish] = useState(false)
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
|
||||
const runAction = async (name: string, fn: () => Promise<string | void>) => {
|
||||
setBusyAction(name)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const msg = await fn()
|
||||
if (msg) setNotice(msg)
|
||||
setReloadNonce((n) => n + 1)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const checkForUpdate = () => runAction('check', async () => {
|
||||
const r = await window.ipc.invoke('apps:checkUpdate', { folder })
|
||||
setUpdateInfo(r)
|
||||
return r.updateAvailable ? `v${r.latest} is available (you have v${r.current}).` : `Up to date (v${r.current}).`
|
||||
})
|
||||
|
||||
const doUpdate = () => runAction('update', async () => {
|
||||
try {
|
||||
await window.ipc.invoke('apps:update', { folder })
|
||||
return 'Updated.'
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
// D18 / modified-files confirmation flows (§12.3)
|
||||
if (msg.includes('new_capabilities')) {
|
||||
if (!window.confirm(`This update widens the app's access:\n${msg}\n\nProceed?`)) return
|
||||
await window.ipc.invoke('apps:update', { folder, confirmNewCapabilities: true, confirmOverwriteModified: true })
|
||||
return 'Updated (new capabilities confirmed).'
|
||||
}
|
||||
if (msg.includes('modified_files')) {
|
||||
if (!window.confirm(`You modified files this update will overwrite:\n${msg}\n\nProceed?`)) return
|
||||
await window.ipc.invoke('apps:update', { folder, confirmOverwriteModified: true })
|
||||
return 'Updated (local modifications overwritten).'
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
||||
const doRollback = () => runAction('rollback', async () => {
|
||||
await window.ipc.invoke('apps:rollback', { folder })
|
||||
return 'Rolled back to the previous version.'
|
||||
})
|
||||
|
||||
const doUninstall = () => runAction('uninstall', async () => {
|
||||
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
|
||||
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
|
||||
await window.ipc.invoke('apps:uninstall', { folder })
|
||||
onClose()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:get', { folder })
|
||||
if (cancelled) return
|
||||
setApp(r.app)
|
||||
setReadme(r.readme)
|
||||
setRollback(r.rollbackAvailable)
|
||||
const rows: AgentRow[] = []
|
||||
for (const slug of r.app.agentSlugs) {
|
||||
try {
|
||||
const t = await window.ipc.invoke('bg-task:get', { slug })
|
||||
if (t.task) {
|
||||
rows.push({
|
||||
slug,
|
||||
name: t.task.name,
|
||||
active: t.task.active,
|
||||
lastRunAt: t.task.lastRunAt,
|
||||
lastRunError: t.task.lastRunError,
|
||||
})
|
||||
}
|
||||
} catch { /* not materialized yet */ }
|
||||
}
|
||||
if (!cancelled) setAgents(rows)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [folder, reloadNonce])
|
||||
|
||||
const toggleAgent = async (slug: string, active: boolean) => {
|
||||
setAgents((prev) => prev.map((a) => (a.slug === slug ? { ...a, active } : a)))
|
||||
try {
|
||||
await window.ipc.invoke('bg-task:patch', { slug, partial: { active } })
|
||||
} catch {
|
||||
setReloadNonce((n) => n + 1) // revert to truth on failure
|
||||
}
|
||||
}
|
||||
|
||||
const runAgent = async (slug: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('bg-task:run', { slug })
|
||||
} catch { /* surfaced via bg-task UI */ }
|
||||
}
|
||||
|
||||
const manifest = app?.manifest
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="flex-1 truncate text-sm font-semibold">{manifest?.name ?? folder}</span>
|
||||
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
|
||||
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
|
||||
{notice && <div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground">{notice}</div>}
|
||||
{!app ? (
|
||||
<div className="text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">App</div>
|
||||
{app.status === 'invalid' && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Invalid manifest: {app.manifestError}
|
||||
</div>
|
||||
)}
|
||||
<InfoRow k="Version" v={manifest ? `v${manifest.version}` : '—'} />
|
||||
<InfoRow k="Folder" v={app.folder} />
|
||||
<InfoRow k="Origin" v={app.origin} mono />
|
||||
{manifest?.description ? <p className="pt-1 text-muted-foreground">{manifest.description}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Capabilities</div>
|
||||
{manifest && manifest.capabilities.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{manifest.capabilities.map((c) => (
|
||||
<span key={c} className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">None declared — this app 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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
155
apps/x/apps/renderer/src/components/apps/app-frame.tsx
Normal file
155
apps/x/apps/renderer/src/components/apps/app-frame.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, BadgeCheck, ExternalLink, Info, RotateCw, UploadCloud } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { appOpened } from '@/lib/analytics'
|
||||
import { AppDetail } from '@/components/apps/app-detail'
|
||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||
|
||||
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
|
||||
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
|
||||
// open-in-browser, detail panel.
|
||||
|
||||
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [showPublish, setShowPublish] = useState(false)
|
||||
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
|
||||
// surface a visible retry state instead of a silent blank pane.
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
|
||||
const title = app.manifest?.name ?? app.folder
|
||||
|
||||
useEffect(() => {
|
||||
appOpened(app.folder)
|
||||
}, [app.folder])
|
||||
|
||||
// Reset the watchdog when the target changes (adjust-during-render pattern).
|
||||
const [watchKey, setWatchKey] = useState(`${app.folder}:${reloadNonce}`)
|
||||
if (watchKey !== `${app.folder}:${reloadNonce}`) {
|
||||
setWatchKey(`${app.folder}:${reloadNonce}`)
|
||||
setLoadState('loading')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoadState((s) => (s === 'loading' ? 'stuck' : s))
|
||||
}, 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [watchKey])
|
||||
|
||||
// Stuck diagnosis: the usual cause is the apps server not being reachable
|
||||
// yet (it starts with the main process; on a fresh launch or a quick
|
||||
// relaunch the first iframe load can beat it). Say when the server itself
|
||||
// is the problem.
|
||||
const [serverDown, setServerDown] = useState(false)
|
||||
useEffect(() => {
|
||||
if (loadState !== 'stuck') return
|
||||
let cancelled = false
|
||||
void window.ipc.invoke('apps:serverStatus', {}).then((s) => {
|
||||
if (!cancelled) setServerDown(!s.running)
|
||||
}).catch(() => { /* status probe is best-effort */ })
|
||||
return () => { cancelled = true }
|
||||
}, [loadState])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Apps
|
||||
</button>
|
||||
<span className="flex-1 truncate text-sm font-medium">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Reload"
|
||||
onClick={() => setReloadNonce((n) => n + 1)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RotateCw className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Open in browser"
|
||||
onClick={() => window.open(app.origin, '_blank')}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</button>
|
||||
{app.kind === 'local' && (
|
||||
app.publish ? (
|
||||
<button
|
||||
type="button"
|
||||
title={`Published as ${app.publish.repo} — view details`}
|
||||
onClick={() => setShowDetail(true)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500"
|
||||
>
|
||||
<BadgeCheck className="size-4" /> Published
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
title="Publish this app"
|
||||
onClick={() => setShowPublish(true)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<UploadCloud className="size-4" /> Publish
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="App details"
|
||||
onClick={() => setShowDetail((v) => !v)}
|
||||
className={`rounded-md p-1.5 hover:bg-accent hover:text-foreground ${showDetail ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<iframe
|
||||
key={reloadNonce}
|
||||
title={title}
|
||||
src={`${app.origin}/`}
|
||||
onLoad={() => setLoadState('ok')}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
{loadState === 'stuck' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadNonce((n) => n + 1)}
|
||||
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div className="max-w-xs text-center text-xs text-muted-foreground">
|
||||
If it still doesn'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>
|
||||
)
|
||||
}
|
||||
244
apps/x/apps/renderer/src/components/apps/apps-view.tsx
Normal file
244
apps/x/apps/renderer/src/components/apps/apps-view.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { AppFrame } from '@/components/apps/app-frame'
|
||||
import { CatalogTab } from '@/components/apps/catalog'
|
||||
|
||||
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
|
||||
// AppSummary-driven; click opens the app full-height on its own origin.
|
||||
|
||||
type Theme = { accent: string; glow: string }
|
||||
|
||||
const THEMES: Theme[] = [
|
||||
{ accent: '#FF4D8D', glow: 'rgba(255,77,141,0.45)' }, // Pink
|
||||
{ accent: '#EF4444', glow: 'rgba(239,68,68,0.45)' }, // Red
|
||||
{ accent: '#22C55E', glow: 'rgba(34,197,94,0.40)' }, // Emerald
|
||||
{ accent: '#F59E0B', glow: 'rgba(245,158,11,0.42)' }, // Amber
|
||||
{ accent: '#14B8A6', glow: 'rgba(20,184,166,0.40)' }, // Teal
|
||||
{ accent: '#EC4899', glow: 'rgba(236,72,153,0.42)' }, // Rose
|
||||
]
|
||||
const PATTERNS = ['dots', 'grid', 'diagonal', 'radial', 'waves', 'mesh', 'cross', 'rings', 'zigzag', 'plus', 'checker', 'beams']
|
||||
|
||||
function hash(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
||||
return Math.abs(h)
|
||||
}
|
||||
const themeForIndex = (i: number): Theme => THEMES[i % THEMES.length]
|
||||
const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length]
|
||||
|
||||
const CARD_CSS = `
|
||||
.ma-page {
|
||||
container-type: inline-size;
|
||||
--ma-bg:#f8f8f9;
|
||||
--ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
|
||||
--ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
|
||||
--ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
|
||||
--ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15);
|
||||
--ma-shadow:0 1px 2px rgba(0,0,0,0.08);
|
||||
--ma-title:#0d0e11; --ma-desc:rgba(0,0,0,0.6);
|
||||
--ma-h1:#0d0e11; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42);
|
||||
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5);
|
||||
--ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4);
|
||||
--ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24;
|
||||
--ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%;
|
||||
height:100%; overflow:auto; background:var(--ma-bg);
|
||||
}
|
||||
.dark .ma-page {
|
||||
--ma-bg:#0b0b0d;
|
||||
--ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
|
||||
--ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
|
||||
--ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
|
||||
--ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12);
|
||||
--ma-shadow:0 1px 2px rgba(0,0,0,0.35);
|
||||
--ma-title:#f4f5f7; --ma-desc:rgba(255,255,255,0.66);
|
||||
--ma-h1:#f4f5f7; --ma-sub:rgba(255,255,255,0.52); --ma-lastrun:rgba(255,255,255,0.38);
|
||||
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5);
|
||||
--ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38);
|
||||
--ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
|
||||
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
|
||||
}
|
||||
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
|
||||
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
|
||||
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); }
|
||||
.ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); }
|
||||
.ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.ma-tab.on { color:var(--ma-title); border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--ma-title) 6%, transparent); }
|
||||
.ma-banner { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:var(--ma-title); border-radius:12px; padding:10px 14px; font-size:13px; margin-bottom:16px; }
|
||||
.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
|
||||
.ma-card {
|
||||
position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px;
|
||||
border:1px solid var(--ma-border);
|
||||
background:
|
||||
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 34%),
|
||||
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint), transparent) 0%, transparent 62%),
|
||||
linear-gradient(158deg, var(--ma-card-from) 0%, var(--ma-card-mid) 52%, var(--ma-card-to) 100%);
|
||||
padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden;
|
||||
display:flex; flex-direction:column; isolation:isolate;
|
||||
box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow);
|
||||
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease;
|
||||
}
|
||||
.ma-card:hover {
|
||||
border-color: var(--ma-border-hover);
|
||||
background:
|
||||
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 36%),
|
||||
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%),
|
||||
linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%);
|
||||
}
|
||||
.ma-card::before { content:''; position:absolute; inset:0; z-index:-1; opacity:var(--ma-pat-opacity); pointer-events:none; }
|
||||
.ma-card::after {
|
||||
content:''; position:absolute; top:-45%; right:-25%; width:75%; height:75%; z-index:-1;
|
||||
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
|
||||
opacity:var(--ma-glow-opacity); filter: blur(18px); pointer-events:none; transition: opacity .22s ease;
|
||||
}
|
||||
.ma-card:hover::after { opacity:var(--ma-glow-hover-opacity); }
|
||||
.ma-pat-dots::before { background-image: radial-gradient(var(--accent) 1px, transparent 1.4px); background-size:16px 16px; }
|
||||
.ma-pat-grid::before { background-image: linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size:26px 26px; }
|
||||
.ma-pat-diagonal::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 14px); }
|
||||
.ma-pat-radial::before { background-image: radial-gradient(circle at 78% 18%, var(--accent) 0%, transparent 55%); opacity:calc(var(--ma-pat-opacity) + 0.05); }
|
||||
.ma-pat-waves::before { background-image: repeating-radial-gradient(circle at 50% -30%, transparent 0 20px, var(--accent) 20px 21px); }
|
||||
.ma-pat-mesh::before { background-image: radial-gradient(circle at 12% 18%, var(--accent) 0%, transparent 42%), radial-gradient(circle at 88% 82%, var(--accent) 0%, transparent 42%); opacity:calc(var(--ma-pat-opacity) + 0.03); }
|
||||
.ma-pat-cross::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 18px), repeating-linear-gradient(-45deg, var(--accent) 0 1px, transparent 1px 18px); }
|
||||
.ma-pat-rings::before { background-image: repeating-radial-gradient(circle at 82% 20%, transparent 0 14px, var(--accent) 14px 15px); }
|
||||
.ma-pat-zigzag::before { background-image: linear-gradient(135deg, var(--accent) 25%, transparent 25%), linear-gradient(225deg, var(--accent) 25%, transparent 25%); background-size: 22px 12px; background-position: 0 0, 11px 0; opacity:calc(var(--ma-pat-opacity) - 0.03); }
|
||||
.ma-pat-plus::before { background-image: radial-gradient(var(--accent) 0.8px, transparent 1px), linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size: 24px 24px, 24px 24px, 24px 24px; background-position: 12px 12px, 0 11.5px, 11.5px 0; opacity:calc(var(--ma-pat-opacity) - 0.02); }
|
||||
.ma-pat-checker::before { background-image: repeating-conic-gradient(var(--accent) 0% 25%, transparent 0% 50%); background-size: 26px 26px; opacity:calc(var(--ma-pat-opacity) - 0.04); }
|
||||
.ma-pat-beams::before { background-image: repeating-linear-gradient(100deg, var(--accent) 0 2px, transparent 2px 34px); }
|
||||
.ma-top { display:flex; justify-content:flex-end; gap:6px; }
|
||||
.ma-badge {
|
||||
display:inline-flex; align-items:center; height:22px; padding:0 10px; border-radius:999px;
|
||||
font-size:9.5px; font-weight:600; letter-spacing:0.07em;
|
||||
color: var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-badge-mix), transparent);
|
||||
}
|
||||
.ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); }
|
||||
.ma-badge.err { color:#ef4444; background:rgba(239,68,68,.14); }
|
||||
.ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; }
|
||||
.ma-desc {
|
||||
font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
|
||||
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
||||
}
|
||||
.ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; }
|
||||
.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ma-new {
|
||||
width:100%; font:inherit; min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
|
||||
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:8px; color:var(--ma-new-title); cursor:pointer; transition: border-color .2s ease, background .2s ease;
|
||||
}
|
||||
.ma-new:hover { border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--accent, #888) 6%, transparent); }
|
||||
.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
|
||||
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
|
||||
.ma-empty { padding:36px 8px; text-align:center; color:var(--ma-sub); font-size:14px; grid-column:1/-1; }
|
||||
@container (max-width: 380px) {
|
||||
.ma-footer { flex-direction:column; align-items:flex-start; gap:6px; }
|
||||
}
|
||||
`
|
||||
|
||||
function Card({ app, index, onOpen }: { app: rowboatApp.AppSummary; index: number; onOpen: () => void }) {
|
||||
const theme = themeForIndex(index)
|
||||
const pattern = patternFor(app.folder)
|
||||
const invalid = app.status === 'invalid'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={invalid ? app.manifestError : undefined}
|
||||
className={`ma-card ma-pat-${pattern}`}
|
||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||
>
|
||||
<div className="ma-top">
|
||||
{invalid && <span className="ma-badge err">INVALID</span>}
|
||||
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
|
||||
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
|
||||
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
|
||||
<span className="ma-lastrun">{app.folder}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||
initialAppFolder?: string | null
|
||||
initialVersion?: number
|
||||
onNewApp?: () => void
|
||||
} = {}) {
|
||||
const [tab, setTab] = useState<'mine' | 'catalog'>('mine')
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
|
||||
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
|
||||
// Open a specific app when asked from outside (app-navigation open-app).
|
||||
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
|
||||
if (initialVersion !== appliedVersion) {
|
||||
setAppliedVersion(initialVersion)
|
||||
if (initialAppFolder) setSelectedFolder(initialAppFolder)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:list', {})
|
||||
if (cancelled) return
|
||||
setApps(r.apps)
|
||||
// Drop a selection whose app no longer exists (uninstalled while
|
||||
// open). Left stale, a later reinstall makes this view yank the user
|
||||
// into the app frame mid-flow — e.g. while they're in the catalog's
|
||||
// post-install agent dialog.
|
||||
setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur))
|
||||
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
|
||||
} catch (e) {
|
||||
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void load()
|
||||
const interval = setInterval(load, 4000) // keep the grid fresh (copilot installs)
|
||||
return () => { cancelled = true; clearInterval(interval) }
|
||||
}, [initialVersion])
|
||||
|
||||
const selected = selectedFolder ? apps.find((a) => a.folder === selectedFolder) : undefined
|
||||
if (selected) {
|
||||
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ma-page">
|
||||
<style>{CARD_CSS}</style>
|
||||
<div className="ma-inner">
|
||||
<h1 className="ma-h1">Apps</h1>
|
||||
<p className="ma-sub">Apps that live inside Rowboat, powered by your agents and integrations.</p>
|
||||
|
||||
<div className="ma-tabs">
|
||||
<button type="button" className={`ma-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
||||
<button type="button" className={`ma-tab${tab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="ma-banner">
|
||||
<RefreshCw className="mr-1.5 inline size-3.5" /> Apps server unavailable: {serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'catalog' ? (
|
||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||
) : (
|
||||
<div className="ma-grid">
|
||||
{apps.map((app, i) => (
|
||||
<Card key={app.folder} app={app} index={i} onOpen={() => setSelectedFolder(app.folder)} />
|
||||
))}
|
||||
<button type="button" className="ma-new" onClick={onNewApp}>
|
||||
<Plus className="size-5" />
|
||||
<div className="ma-new-title">New app</div>
|
||||
<div className="ma-new-hint">Describe one to the copilot</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
381
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal file
381
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
|
||||
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||
// disclosure, install from a direct bundle URL.
|
||||
|
||||
type Preview = {
|
||||
name?: string
|
||||
version?: string
|
||||
description?: string
|
||||
capabilities?: string[]
|
||||
agents?: string[]
|
||||
updateSource?: 'github' | 'none'
|
||||
url?: string // set for URL installs
|
||||
}
|
||||
|
||||
function capabilityDescription(cap: string): string {
|
||||
if (cap === 'llm') return 'use your AI models (spends your tokens)'
|
||||
if (cap === 'copilot') return 'run the copilot agent on your behalf (tools + your knowledge)'
|
||||
return `read and act on your ${cap} through your connected account`
|
||||
}
|
||||
|
||||
/** D18 disclosure dialog: every declared capability + bundled agent, explicit confirm. */
|
||||
function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: {
|
||||
preview: Preview
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const caps = preview.capabilities ?? []
|
||||
const agents = preview.agents ?? []
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-1 text-base font-semibold">Install {preview.name} v{preview.version}?</div>
|
||||
{preview.description && <p className="mb-3 text-sm text-muted-foreground">{preview.description}</p>}
|
||||
|
||||
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 font-medium">
|
||||
<ShieldAlert className="size-4 text-amber-500" /> This app will be able to:
|
||||
</div>
|
||||
{caps.length === 0 ? (
|
||||
<p className="text-muted-foreground">Nothing — it declares no capabilities (no tools, LLM, or copilot access).</p>
|
||||
) : (
|
||||
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||
{caps.map((c) => <li key={c}><span className="font-medium text-foreground">{c}</span>: {capabilityDescription(c)}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{agents.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Bundled background agents (installed disabled):</div>
|
||||
<ul className="list-inside list-disc text-muted-foreground">
|
||||
{agents.map((a) => <li key={a}>{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preview.updateSource === 'none' && (
|
||||
<p className="mb-3 text-xs text-amber-600 dark:text-amber-400">Installed from a direct URL — updates will be unavailable.</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
|
||||
<button type="button" onClick={onConfirm} disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
{busy ? 'Installing…' : 'Install'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelChoice = { provider: string; model: string }
|
||||
|
||||
/** Post-install opt-in (§8.3): bundled agents land disabled; without this
|
||||
* prompt a fresh installer opens an empty app with no hint that the refresher
|
||||
* exists, buried in bg-tasks. The model picker defaults to the host-pinned
|
||||
* model but lets the user override before the first run. */
|
||||
function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSkip }: {
|
||||
appName: string
|
||||
names: string[]
|
||||
defaultModel?: ModelChoice
|
||||
busy: boolean
|
||||
onEnable: (model: ModelChoice | null) => void
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const [options, setOptions] = useState<Array<ModelChoice & { label: string }>>([])
|
||||
const [selected, setSelected] = useState<string>(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '')
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('models:list', null)
|
||||
setOptions(r.providers.flatMap((p) => p.models.map((m) => ({
|
||||
provider: p.id,
|
||||
model: m.id,
|
||||
label: `${m.name ?? m.id} (${p.name})`,
|
||||
}))))
|
||||
} catch { /* no picker — enable keeps the pinned model */ }
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const choice = (): ModelChoice | null => {
|
||||
const [provider, model] = selected.split('::')
|
||||
return provider && model ? { provider, model } : null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-1 flex items-center gap-2 text-base font-semibold">
|
||||
<Bot className="size-5" /> Turn on {names.length === 1 ? 'its background agent' : 'its background agents'}?
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{appName} ships {names.length === 1 ? 'an agent' : 'agents'} that keep{names.length === 1 ? 's' : ''} its
|
||||
data fresh on a schedule, using your connected accounts and AI models. {names.length === 1 ? 'It is' : 'They are'} currently off.
|
||||
</p>
|
||||
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
|
||||
{names.map((n) => <li key={n}>{n}</li>)}
|
||||
</ul>
|
||||
{options.length > 0 && (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Run with
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)}
|
||||
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
|
||||
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
|
||||
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onSkip} disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
|
||||
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
{busy ? 'Turning on…' : 'Turn on & run now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
|
||||
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
|
||||
const [stale, setStale] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<Preview | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [urlDialog, setUrlDialog] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[]; defaultModel?: ModelChoice } | null>(null)
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
// Registry name → local folder, for apps already installed from the catalog.
|
||||
const [installedByName, setInstalledByName] = useState<Map<string, string>>(new Map())
|
||||
|
||||
const loadInstalled = async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:list', {})
|
||||
setInstalledByName(new Map(
|
||||
r.apps.filter((a) => a.kind === 'installed' && a.install).map((a) => [a.install!.name, a.folder]),
|
||||
))
|
||||
} catch { /* cards just show Install */ }
|
||||
}
|
||||
useEffect(() => { void loadInstalled() }, [])
|
||||
|
||||
const load = async (force = false) => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:catalogIndex', { force })
|
||||
setRecords(r.records)
|
||||
setStale(r.stale)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
useEffect(() => { void load() }, [])
|
||||
|
||||
const search = async (q: string) => {
|
||||
setQuery(q)
|
||||
try {
|
||||
const r = q.trim()
|
||||
? await window.ipc.invoke('apps:catalogSearch', { query: q })
|
||||
: await window.ipc.invoke('apps:catalogIndex', {})
|
||||
setRecords(r.records)
|
||||
} catch { /* keep current list */ }
|
||||
}
|
||||
|
||||
const startInstall = async (name: string) => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:install', { name })
|
||||
if (r.status === 'preview') setPreview({ ...r })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const startUrlPreview = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:installFromUrl', { url: url.trim(), confirmed: false })
|
||||
if (r.status === 'preview') setPreview({ ...r, url: url.trim() })
|
||||
setUrlDialog(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const enableAgents = async (model: ModelChoice | null) => {
|
||||
if (!agentPrompt) return
|
||||
setEnabling(true)
|
||||
try {
|
||||
for (const slug of agentPrompt.slugs) {
|
||||
await window.ipc.invoke('bg-task:patch', {
|
||||
slug,
|
||||
partial: { active: true, ...(model ? { model: model.model, provider: model.provider } : {}) },
|
||||
})
|
||||
// First run so the app opens with data; resolves when the run ends, so
|
||||
// fire-and-forget.
|
||||
void window.ipc.invoke('bg-task:run', { slug }).catch(() => { /* surfaced in bg-tasks */ })
|
||||
}
|
||||
} catch { /* patch failures land the user in the same place as "Not now" */ }
|
||||
const folder = agentPrompt.folder
|
||||
setAgentPrompt(null)
|
||||
setEnabling(false)
|
||||
onInstalled(folder)
|
||||
}
|
||||
|
||||
const confirmInstall = async () => {
|
||||
if (!preview) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = preview.url
|
||||
? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true })
|
||||
: await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true })
|
||||
setPreview(null)
|
||||
void loadInstalled()
|
||||
if (r.status === 'installed' && r.app) {
|
||||
if (r.app.agentSlugs.length > 0) {
|
||||
// Offer to switch the bundled agents on (they install disabled).
|
||||
let defaultModel: ModelChoice | undefined
|
||||
const names = await Promise.all(r.app.agentSlugs.map(async (slug) => {
|
||||
try {
|
||||
const g = await window.ipc.invoke('bg-task:get', { slug })
|
||||
if (g.task?.model && g.task.provider) defaultModel = { model: g.task.model, provider: g.task.provider }
|
||||
return g.task?.name ?? slug
|
||||
} catch {
|
||||
return slug
|
||||
}
|
||||
}))
|
||||
setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names, defaultModel })
|
||||
} else {
|
||||
onInstalled(r.app.folder)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => void search(e.target.value)}
|
||||
placeholder="Search the catalog…"
|
||||
className="w-full rounded-lg border border-border bg-background py-2 pl-8 pr-3 text-sm outline-none focus:border-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" title="Install from URL"
|
||||
onClick={() => setUrlDialog(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-accent">
|
||||
<Link2 className="size-4" /> From URL
|
||||
</button>
|
||||
{stale && (
|
||||
<button type="button" onClick={() => void load(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm font-medium">
|
||||
<RefreshCw className="size-4" /> Stale — refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{records.map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-semibold">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">by {r.owner}</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
||||
</div>
|
||||
{installedByName.has(r.name) ? (
|
||||
<button type="button" title="Installed — open it"
|
||||
onClick={() => onInstalled(installedByName.get(r.name)!)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
|
||||
<BadgeCheck className="size-4" /> Installed
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => void startInstall(r.name)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
<Download className="size-4" /> Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{urlDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-2 text-base font-semibold">Install from URL</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">Paste a direct https link to a <code>.rowboat-app</code> bundle (e.g. a GitHub release asset).</p>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://github.com/owner/repo/releases/download/v1.0.0/name.rowboat-app"
|
||||
className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-foreground/30"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setUrlDialog(false)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
|
||||
<button type="button" onClick={() => void startUrlPreview()} disabled={!url.trim().startsWith('https://')}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<InstallConfirmDialog
|
||||
preview={preview}
|
||||
busy={busy}
|
||||
onConfirm={() => void confirmInstall()}
|
||||
onCancel={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentPrompt && (
|
||||
<EnableAgentsDialog
|
||||
appName={agentPrompt.appName}
|
||||
names={agentPrompt.names}
|
||||
defaultModel={agentPrompt.defaultModel}
|
||||
busy={enabling}
|
||||
onEnable={(model) => void enableAgents(model)}
|
||||
onSkip={() => {
|
||||
const folder = agentPrompt.folder
|
||||
setAgentPrompt(null)
|
||||
onInstalled(folder)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
220
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal file
220
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react'
|
||||
|
||||
// Publish dialog (spec §14): device-flow sign-in → confirmation → step
|
||||
// progress (§11.2 step names) → success links / typed error (name_taken gets
|
||||
// an inline hint to rename and retry). With `published` the dialog runs the
|
||||
// UPDATE path instead (§11.3: version bump + new release, no registry) —
|
||||
// re-running first-publish on a published app always fails with name_taken.
|
||||
|
||||
type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error'
|
||||
type Increment = 'patch' | 'minor' | 'major'
|
||||
|
||||
export function PublishDialog({ folder, appName, published, onClose, onPublished }: {
|
||||
folder: string
|
||||
appName: string
|
||||
/** App already has a publish record — run publish-update instead. */
|
||||
published?: boolean
|
||||
onClose: () => void
|
||||
onPublished: () => void
|
||||
}) {
|
||||
const [phase, setPhase] = useState<Phase>('auth')
|
||||
const [login, setLogin] = useState<string | undefined>()
|
||||
const [userCode, setUserCode] = useState('')
|
||||
const [steps, setSteps] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [increment, setIncrement] = useState<Increment>('patch')
|
||||
const [result, setResult] = useState<{ repoUrl?: string; releaseUrl: string; prUrl?: string; status: string; version?: string } | null>(null)
|
||||
const pollTimer = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await window.ipc.invoke('githubAuth:status', {})
|
||||
if (s.signedIn) {
|
||||
setLogin(s.login)
|
||||
setPhase('confirm')
|
||||
}
|
||||
})()
|
||||
return () => { if (pollTimer.current) window.clearInterval(pollTimer.current) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return window.ipc.on('apps:progress', ({ folder: f, step }) => {
|
||||
if (f === folder) setSteps((prev) => (prev.includes(step) ? prev : [...prev, step]))
|
||||
})
|
||||
}, [folder])
|
||||
|
||||
const startSignIn = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('githubAuth:start', {})
|
||||
setUserCode(r.userCode)
|
||||
setPhase('device')
|
||||
pollTimer.current = window.setInterval(() => {
|
||||
void (async () => {
|
||||
const p = await window.ipc.invoke('githubAuth:poll', {})
|
||||
if (p.status === 'authorized') {
|
||||
if (pollTimer.current) window.clearInterval(pollTimer.current)
|
||||
setLogin(p.login)
|
||||
setPhase('confirm')
|
||||
} else if (p.status === 'expired' || p.status === 'denied') {
|
||||
if (pollTimer.current) window.clearInterval(pollTimer.current)
|
||||
setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.')
|
||||
setPhase('auth')
|
||||
}
|
||||
})().catch((e: unknown) => {
|
||||
// A hard failure (bad client config, network) must surface, not spin.
|
||||
if (pollTimer.current) window.clearInterval(pollTimer.current)
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setPhase('auth')
|
||||
})
|
||||
// Heartbeat only — core paces the actual GitHub requests to the
|
||||
// flow's required interval (and skips the request when it's too soon).
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const publish = async () => {
|
||||
setPhase('publishing')
|
||||
setError(null)
|
||||
setSteps([])
|
||||
try {
|
||||
if (published) {
|
||||
const r = await window.ipc.invoke('apps:publishUpdate', { folder, increment })
|
||||
setResult({ releaseUrl: r.releaseUrl, status: 'published', version: r.version })
|
||||
} else {
|
||||
const r = await window.ipc.invoke('apps:publish', { folder })
|
||||
setResult(r)
|
||||
}
|
||||
setPhase('done')
|
||||
onPublished()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
packaged: 'Packaged bundle',
|
||||
repo_created: 'Created GitHub repo',
|
||||
source_pushed: 'Pushed source',
|
||||
release_created: 'Created release',
|
||||
assets_uploaded: 'Uploaded assets',
|
||||
registered: 'Opened registry PR',
|
||||
polling: 'Waiting for registry validation…',
|
||||
published: 'Published',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<Github className="size-5" /> Publish {appName}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
{error.includes('name_taken') && <div className="mt-1 text-xs">That name is taken — rename the app in <code>rowboat-app.json</code> and retry.</div>}
|
||||
</div>}
|
||||
|
||||
{phase === 'auth' && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">Publishing creates a public GitHub repo under your account, uploads the app as a release, and lists it in the Rowboat catalog. A generated MIT LICENSE is added if your app has none.</p>
|
||||
<button type="button" onClick={() => void startSignIn()}
|
||||
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'device' && (
|
||||
<div className="space-y-3 text-center text-sm">
|
||||
<p className="text-muted-foreground">Enter this code on the GitHub page that just opened:</p>
|
||||
<div className="select-all rounded-lg border border-border bg-muted/40 py-3 font-mono text-2xl font-bold tracking-widest">{userCode}</div>
|
||||
<p className="flex items-center justify-center gap-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Waiting for authorization…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'confirm' && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Signed in as <span className="font-medium text-foreground">@{login}</span>.{' '}
|
||||
{published
|
||||
? <>This will publish a new version of <span className="font-medium text-foreground">{appName}</span> (a new release on the existing repo).</>
|
||||
: <>This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</>}
|
||||
</p>
|
||||
{published && (
|
||||
<label className="flex items-center gap-2 text-muted-foreground">
|
||||
Version bump
|
||||
<select value={increment} onChange={(e) => setIncrement(e.target.value as Increment)}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm">
|
||||
<option value="patch">patch</option>
|
||||
<option value="minor">minor</option>
|
||||
<option value="major">major</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button type="button" onClick={() => void publish()}
|
||||
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
{published ? 'Publish update' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
|
||||
<div className="space-y-3 text-sm">
|
||||
{phase === 'publishing' && (
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Publishing update…
|
||||
</p>
|
||||
)}
|
||||
{phase === 'done' && result && (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-2"><CheckCircle2 className="size-4 text-green-600" /> Published v{result.version}</p>
|
||||
<div className="text-xs">Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{Object.entries(STEP_LABELS).map(([key, label]) => {
|
||||
const doneStep = steps.includes(key) || phase === 'done'
|
||||
const active = phase === 'publishing' && !doneStep && steps[steps.length - 1] !== key
|
||||
if (key === 'published' && phase !== 'done') return null
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
{doneStep
|
||||
? <CheckCircle2 className="size-4 text-green-600" />
|
||||
: phase === 'error'
|
||||
? <XCircle className="size-4 text-muted-foreground/40" />
|
||||
: <Loader2 className={`size-4 ${active ? 'text-muted-foreground/40' : 'animate-spin text-muted-foreground'}`} />}
|
||||
<span className={doneStep ? '' : 'text-muted-foreground'}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{phase === 'done' && result && (
|
||||
<div className="mt-3 space-y-1 text-xs">
|
||||
<div>Repo: <a className="text-primary underline" href={result.repoUrl} target="_blank" rel="noreferrer">{result.repoUrl}</a></div>
|
||||
<div>Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
|
||||
{result.status === 'pending' && result.prUrl && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Registry validation still pending — track it at <a className="underline" href={result.prUrl} target="_blank" rel="noreferrer">the PR</a>.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" onClick={onClose}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
{phase === 'done' ? 'Done' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,10 +4,9 @@ import {
|
|||
ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle,
|
||||
Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight,
|
||||
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
|
||||
Code2, FolderOpen, LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import type { z } from 'zod'
|
||||
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -16,9 +15,10 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
|
|||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger helpers (inlined; extract to shared <TriggersEditor> as a follow-up)
|
||||
|
|
@ -270,7 +270,16 @@ function TriggersEditor({
|
|||
// New Task dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DialogMode = 'describe' | 'manual'
|
||||
type DialogMode = 'describe' | 'manual' | 'templates' | 'coding'
|
||||
|
||||
// Prefills for the "Coding from meetings" preset.
|
||||
const CODING_PRESET = {
|
||||
name: 'Implement coding items from meetings',
|
||||
instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team.
|
||||
|
||||
Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool — group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`,
|
||||
eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`,
|
||||
}
|
||||
|
||||
function NewTaskDialog({
|
||||
open,
|
||||
|
|
@ -294,6 +303,9 @@ function NewTaskDialog({
|
|||
const [name, setName] = useState('')
|
||||
const [instructions, setInstructions] = useState('')
|
||||
const [triggers, setTriggers] = useState<Triggers | undefined>(undefined)
|
||||
const [projectId, setProjectId] = useState<string | undefined>(undefined)
|
||||
const [projectName, setProjectName] = useState<string | undefined>(undefined)
|
||||
const [addingProject, setAddingProject] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -303,11 +315,64 @@ function NewTaskDialog({
|
|||
setName('')
|
||||
setInstructions('')
|
||||
setTriggers(undefined)
|
||||
setProjectId(undefined)
|
||||
setProjectName(undefined)
|
||||
}
|
||||
}, [open, copilotEnabled])
|
||||
|
||||
// Switch into the coding preset: prefill name/instructions/trigger once.
|
||||
const enterCodingMode = () => {
|
||||
setMode('coding')
|
||||
setName(CODING_PRESET.name)
|
||||
setInstructions(CODING_PRESET.instructions)
|
||||
setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria })
|
||||
}
|
||||
|
||||
const pickRepo = async () => {
|
||||
setAddingProject(true)
|
||||
try {
|
||||
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' })
|
||||
const dir = res.path
|
||||
if (!dir) return
|
||||
const added = await window.ipc.invoke('codeProject:add', { path: dir })
|
||||
if (!added.git?.isGitRepo) {
|
||||
toast('That folder is not a git repository — coding tasks need one.', 'error')
|
||||
return
|
||||
}
|
||||
setProjectId(added.project.id)
|
||||
setProjectName(added.project.name)
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : String(err), 'error')
|
||||
} finally {
|
||||
setAddingProject(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitDescribe = description.trim().length > 0 && !submitting
|
||||
const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting
|
||||
const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting
|
||||
|
||||
const submitCoding = async () => {
|
||||
if (!canSubmitCoding) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('bg-task:create', {
|
||||
name: name.trim(),
|
||||
instructions: instructions.trim(),
|
||||
...(triggers ? { triggers } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
})
|
||||
if (result.success && result.slug) {
|
||||
onCreated(result.slug)
|
||||
} else {
|
||||
toast(result.error ?? 'Failed to create task', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : String(err), 'error')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitDescribe = () => {
|
||||
if (!canSubmitDescribe || !onCreateWithCopilot) return
|
||||
|
|
@ -358,7 +423,116 @@ function NewTaskDialog({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'describe' ? (
|
||||
{(mode === 'describe' || mode === 'manual') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('templates')}
|
||||
className="mb-4 flex w-full items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-left text-[12px] hover:border-solid hover:bg-accent"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutTemplate className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">View available templates</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mode === 'templates' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
id: 'coding-from-meetings',
|
||||
title: 'Coding from meetings',
|
||||
description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.",
|
||||
icon: Code2,
|
||||
onSelect: enterCodingMode,
|
||||
},
|
||||
].map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={preset.onSelect}
|
||||
className="flex w-full items-start gap-2.5 rounded-md border bg-muted/40 px-3 py-2.5 text-left hover:border-foreground/30 hover:bg-accent"
|
||||
>
|
||||
<preset.icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12.5px] font-medium">{preset.title}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">{preset.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'coding' ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Repository</label>
|
||||
{projectName ? (
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-[13px]">
|
||||
<FolderOpen className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{projectName}</span>
|
||||
</span>
|
||||
<button type="button" onClick={pickRepo} className="text-[11px] text-muted-foreground hover:text-foreground" disabled={addingProject}>Change</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={pickRepo} disabled={addingProject}>
|
||||
{addingProject ? <Loader2 className="mr-1 size-3 animate-spin" /> : <FolderOpen className="mr-1 size-3" />}
|
||||
Choose a git repository…
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Code changes run full-auto in an isolated git worktree — your working checkout is never touched.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Name</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Instructions</label>
|
||||
<Textarea value={instructions} onChange={e => setInstructions(e.target.value)} rows={6} className="text-[12.5px] leading-relaxed" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</label>
|
||||
<TriggersEditor value={triggers} onChange={setTriggers} />
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Prefilled to fire when a meeting's notes become available. Adjust if you want.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Cancel</Button>
|
||||
<Button size="sm" onClick={submitCoding} disabled={!canSubmitCoding}>
|
||||
{submitting && <Loader2 className="mr-1 size-3 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'describe' ? (
|
||||
<>
|
||||
<Textarea
|
||||
value={description}
|
||||
|
|
@ -502,15 +676,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output pane — index.md (main pane content)
|
||||
// Output pane — index.html (preferred) or index.md (main pane content)
|
||||
//
|
||||
// Renders the task's `index.md` like a note: max-width 720px centered, same
|
||||
// typography (~16px, 1.5 line-height, generous padding) as the note editor's
|
||||
// ProseMirror rule in `editor.css`. No chrome above the body — just the
|
||||
// markdown, with a small floating Source ⇄ Rendered toggle in the top-right.
|
||||
// A task's agent-owned artifact is either:
|
||||
// - `index.html` — a self-contained, styled web page. Rendered full-bleed in
|
||||
// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace`
|
||||
// protocol) so CSS, layout, and scripts render faithfully. Preferred when
|
||||
// present and non-empty.
|
||||
// - `index.md` — a note. Rendered like the note editor: max-width 720px
|
||||
// centered, same typography as `editor.css`, via `RichMarkdownViewer`.
|
||||
//
|
||||
// In both cases a small floating Source ⇄ Rendered toggle in the top-right
|
||||
// swaps the rendered view for the raw file source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) {
|
||||
const [mode, setMode] = useState<'md' | 'html'>('md')
|
||||
const [body, setBody] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewSource, setViewSource] = useState(false)
|
||||
|
|
@ -519,21 +700,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
|
|||
let cancelled = false
|
||||
setLoading(true)
|
||||
void (async () => {
|
||||
// Prefer index.html when it exists and has content; otherwise fall
|
||||
// back to index.md (the default seeded artifact).
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
const html = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.html`,
|
||||
})
|
||||
if (html.data.trim()) {
|
||||
if (!cancelled) { setMode('html'); setBody(html.data) }
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// No index.html — fall through to markdown.
|
||||
}
|
||||
try {
|
||||
const md = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.md`,
|
||||
})
|
||||
if (!cancelled) setBody(result.data)
|
||||
if (!cancelled) { setMode('md'); setBody(md.data) }
|
||||
} catch {
|
||||
if (!cancelled) setBody('')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
if (!cancelled) { setMode('md'); setBody('') }
|
||||
}
|
||||
})()
|
||||
})().finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [slug, refreshKey])
|
||||
|
||||
const isEmpty = !body.trim() || body.trim() === `# ${taskName}`
|
||||
const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`)
|
||||
const showHtml = mode === 'html' && !viewSource
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden bg-background">
|
||||
|
|
@ -542,29 +735,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
|
|||
type="button"
|
||||
onClick={() => setViewSource(v => !v)}
|
||||
className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground"
|
||||
aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'}
|
||||
aria-label={viewSource ? 'Show rendered output' : 'Show source'}
|
||||
>
|
||||
{viewSource ? 'Rendered' : 'Source'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-[720px] px-16 py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
|
||||
</p>
|
||||
) : viewSource ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
|
||||
) : (
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
{showHtml ? (
|
||||
// Full-bleed: the iframe fills the pane and scrolls internally.
|
||||
// Remount on refreshKey so a re-run's updated index.html reloads.
|
||||
<HtmlFileViewer key={`${slug}-${refreshKey}`} path={`bg-tasks/${slug}/index.html`} />
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-[720px] px-16 py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
|
||||
</p>
|
||||
) : viewSource ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
|
||||
) : (
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -769,10 +968,9 @@ function SetupTab({
|
|||
// Runs history tab — list + drill-down transcript view
|
||||
//
|
||||
// Source of truth: `bg-tasks/<slug>/runs.log` — a plain-text file with one
|
||||
// runId per line (newest first). The actual transcripts live at the global
|
||||
// `$WorkDir/runs/<runId>.jsonl`, so this tab fetches runIds via the bg-task
|
||||
// IPC, then loads each Run through the standard `runs:fetch`. No bg-task-
|
||||
// specific transcript path or schema needed.
|
||||
// turn id per line (newest first). Transcripts live in the turn runtime's
|
||||
// storage; this tab fetches ids via the bg-task IPC, then loads each through
|
||||
// the shared agent-transcript loader (turn-first, legacy-run fallback).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunRowSummary {
|
||||
|
|
@ -783,27 +981,6 @@ interface RunRowSummary {
|
|||
error?: string
|
||||
}
|
||||
|
||||
// Pull the bits we want to display for a row out of a full Run's event log.
|
||||
function summarizeRun(run: z.infer<typeof Run>): RunRowSummary {
|
||||
const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase }
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) {
|
||||
const [rows, setRows] = useState<RunRowSummary[]>([])
|
||||
|
|
@ -815,19 +992,25 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask })
|
|||
setLoading(true)
|
||||
try {
|
||||
const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 })
|
||||
// Fetch each Run in parallel via the canonical IPC. Runs whose
|
||||
// jsonl no longer exists (deleted manually, never written, …) are
|
||||
// dropped silently.
|
||||
// Fetch transcripts in parallel (turn-first, legacy-run
|
||||
// fallback). Ids whose files no longer exist keep a bare row so
|
||||
// the user knows the run happened.
|
||||
const settled = await Promise.allSettled(
|
||||
runIds.map(runId => window.ipc.invoke('runs:fetch', { runId }))
|
||||
runIds.map(runId => fetchAgentRunTranscript(runId))
|
||||
)
|
||||
const next: RunRowSummary[] = []
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const r = settled[i]
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
next.push(summarizeRun(r.value))
|
||||
if (r.status === 'fulfilled') {
|
||||
const t = r.value
|
||||
next.push({
|
||||
runId: t.id,
|
||||
...(t.createdAt === undefined ? {} : { createdAt: t.createdAt }),
|
||||
...(t.trigger === undefined ? {} : { trigger: t.trigger }),
|
||||
...(t.summary === undefined ? {} : { summary: t.summary }),
|
||||
...(t.error === undefined ? {} : { error: t.error }),
|
||||
})
|
||||
} else {
|
||||
// Keep the row visible with just the id so the user knows it exists.
|
||||
next.push({ runId: runIds[i] })
|
||||
}
|
||||
}
|
||||
|
|
@ -933,7 +1116,7 @@ function RunTranscriptView({
|
|||
isInFlight: boolean
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -943,15 +1126,13 @@ function RunTranscriptView({
|
|||
setError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
// Bg-task transcripts now live at the global runs/ location —
|
||||
// same path resolution as every other run, no special handling.
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
|
|
@ -959,8 +1140,8 @@ function RunTranscriptView({
|
|||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
const summary = run ? summarizeRun(run) : undefined
|
||||
const items: ConversationItem[] = run ? runLogToConversation(run.log) : []
|
||||
const summary = transcript ?? undefined
|
||||
const items: ConversationItem[] = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
|
@ -1020,10 +1201,10 @@ function RunTranscriptView({
|
|||
Couldn't load transcript: {error}
|
||||
</div>
|
||||
)}
|
||||
{run && !loading && items.length === 0 && (
|
||||
{transcript && !loading && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loading && items.length > 0 && (
|
||||
{transcript && !loading && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
|
||||
|
||||
import type { HttpAuthRequest } from '@x/shared/dist/browser-control.js'
|
||||
|
||||
import { TabBar } from '@/components/tab-bar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
|
|
@ -86,9 +98,80 @@ const getBrowserTabTitle = (tab: BrowserTabState) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential prompt for HTTP basic/proxy auth challenges raised by pages in
|
||||
* the embedded browser. Rendered as a regular app dialog: BrowserPane already
|
||||
* hides the native WebContentsView whenever a dialog overlay is open, so the
|
||||
* prompt is never obscured by the page that triggered it.
|
||||
*/
|
||||
function BrowserHttpAuthDialog({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
request: HttpAuthRequest
|
||||
onSubmit: (username: string, password: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
// Basic auth allows an empty username (token-style `curl -u :TOKEN`), so the
|
||||
// only invalid submission is fully empty. The server decides the rest.
|
||||
const canSubmit = username.length > 0 || password.length > 0
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
onSubmit(username, password)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onCancel() }}>
|
||||
<DialogContent className="w-[min(24rem,calc(100%-2rem))] max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in</DialogTitle>
|
||||
<DialogDescription>
|
||||
{request.isProxy
|
||||
? `The proxy ${request.host} requires a username and password.`
|
||||
: `${request.host} requires a username and password.`}
|
||||
{request.realm ? ` (${request.realm})` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Input
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
Sign in
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
|
||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
const [authQueue, setAuthQueue] = useState<HttpAuthRequest[]>([])
|
||||
|
||||
const activeTabIdRef = useRef<string | null>(null)
|
||||
const addressFocusedRef = useRef(false)
|
||||
|
|
@ -121,6 +204,51 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
return cleanup
|
||||
}, [applyState])
|
||||
|
||||
// Mirror of authQueue for the unmount handler, which must read the latest
|
||||
// queue without re-subscribing on every change.
|
||||
const authQueueRef = useRef<HttpAuthRequest[]>([])
|
||||
useEffect(() => {
|
||||
authQueueRef.current = authQueue
|
||||
}, [authQueue])
|
||||
|
||||
useEffect(() => {
|
||||
const offRequest = window.ipc.on('browser:httpAuthRequest', (incoming) => {
|
||||
setAuthQueue((queue) => [...queue, incoming as HttpAuthRequest])
|
||||
})
|
||||
// Main resolved a challenge on its own (timeout, or its tab/window was
|
||||
// destroyed) — drop the corresponding dialog so it can't linger over an
|
||||
// unrelated page with a submit that would no-op.
|
||||
const offResolved = window.ipc.on('browser:httpAuthResolved', (incoming) => {
|
||||
const { requestId } = incoming as { requestId: string }
|
||||
setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
})
|
||||
return () => {
|
||||
offRequest()
|
||||
offResolved()
|
||||
// Cancel anything still pending so the main-process login callbacks and
|
||||
// timers are freed immediately instead of waiting out the timeout.
|
||||
for (const request of authQueueRef.current) {
|
||||
void window.ipc.invoke('browser:httpAuthResponse', { requestId: request.requestId })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const respondToAuth = useCallback(
|
||||
(requestId: string, credentials: { username: string; password: string } | null) => {
|
||||
setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
// Omit username to cancel; include it (even empty) to submit.
|
||||
void window.ipc.invoke(
|
||||
'browser:httpAuthResponse',
|
||||
credentials
|
||||
? { requestId, username: credentials.username, password: credentials.password }
|
||||
: { requestId },
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const activeAuthRequest = authQueue[0] ?? null
|
||||
|
||||
const setViewVisible = useCallback((visible: boolean) => {
|
||||
if (viewVisibleRef.current === visible) return
|
||||
viewVisibleRef.current = visible
|
||||
|
|
@ -420,6 +548,17 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
className="relative min-h-0 min-w-0 flex-1"
|
||||
data-browser-viewport
|
||||
/>
|
||||
|
||||
{activeAuthRequest && (
|
||||
<BrowserHttpAuthDialog
|
||||
key={activeAuthRequest.requestId}
|
||||
request={activeAuthRequest}
|
||||
onSubmit={(username, password) =>
|
||||
respondToAuth(activeAuthRequest.requestId, { username, password })
|
||||
}
|
||||
onCancel={() => respondToAuth(activeAuthRequest.requestId, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react'
|
||||
import { Bot, Mail, Sparkles, Telescope } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
export interface ChatEmptyStateRun {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
}
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
recentRuns?: ChatEmptyStateRun[]
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
/** Fill the composer with a starter prompt (does not submit). */
|
||||
onPickPrompt: (prompt: string) => void
|
||||
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
|
||||
|
|
@ -26,13 +17,10 @@ const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt
|
|||
]
|
||||
|
||||
/**
|
||||
* Empty-state body for the chat surface: greeting, recent chats, and starter
|
||||
* action cards. Shown in both the side-pane copilot and full-screen chat.
|
||||
* Empty-state body for the chat surface: greeting and starter action cards.
|
||||
* Shown in both the side-pane copilot and full-screen chat.
|
||||
*/
|
||||
export function ChatEmptyState({
|
||||
recentRuns = [],
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
onPickPrompt,
|
||||
wide = false,
|
||||
}: ChatEmptyStateProps) {
|
||||
|
|
@ -44,45 +32,13 @@ export function ChatEmptyState({
|
|||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or start with a suggestion.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentRuns.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="flex-1">Recent chats</span>
|
||||
{onOpenChatHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenChatHistory}
|
||||
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowUpRight className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{recentRuns.slice(0, 4).map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onSelectRun?.(run.id)}
|
||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
|
||||
Get started
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{SUGGESTED_ACTIONS.map((action) => (
|
||||
|
|
@ -101,6 +57,8 @@ export function ChatEmptyState({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolConnectionsCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type Run = {
|
|||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
agentId: string
|
||||
}
|
||||
|
||||
|
|
@ -51,8 +52,8 @@ export function ChatHistoryView({
|
|||
|
||||
const sortedRuns = useMemo(() => {
|
||||
return [...runs].sort((a, b) => {
|
||||
const at = new Date(a.createdAt).getTime()
|
||||
const bt = new Date(b.createdAt).getTime()
|
||||
const at = new Date(a.modifiedAt).getTime()
|
||||
const bt = new Date(b.modifiedAt).getTime()
|
||||
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
|
||||
})
|
||||
}, [runs])
|
||||
|
|
@ -92,7 +93,7 @@ export function ChatHistoryView({
|
|||
<div className="min-w-[480px]">
|
||||
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex-1">Title</div>
|
||||
<div className="w-32 shrink-0">Created</div>
|
||||
<div className="w-32 shrink-0 text-right">Last modified</div>
|
||||
</div>
|
||||
|
||||
{sortedRuns.length === 0 ? (
|
||||
|
|
@ -122,8 +123,8 @@ export function ChatHistoryView({
|
|||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
|
||||
</div>
|
||||
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{formatRelativeTime(run.createdAt)}
|
||||
<div className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatRelativeTime(run.modifiedAt)}
|
||||
</div>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -91,11 +91,28 @@ interface ChatMessageAttachmentsProps {
|
|||
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||
if (attachments.length === 0) return null
|
||||
|
||||
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
|
||||
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
|
||||
const videoFrames = attachments.filter((attachment) => attachment.isVideoFrame)
|
||||
const imageAttachments = attachments.filter(
|
||||
(attachment) => !attachment.isVideoFrame && isImageMime(attachment.mimeType)
|
||||
)
|
||||
const fileAttachments = attachments.filter(
|
||||
(attachment) => !attachment.isVideoFrame && !isImageMime(attachment.mimeType)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||
{videoFrames.length > 0 && (
|
||||
<div className="flex max-w-[340px] flex-wrap justify-end gap-1">
|
||||
{videoFrames.map((frame, index) => (
|
||||
<img
|
||||
key={`frame-${index}`}
|
||||
src={frame.thumbnailUrl}
|
||||
alt={`Camera frame ${index + 1}`}
|
||||
className="h-12 w-auto rounded-md border border-border/60 bg-muted object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -37,7 +37,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
|||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
getToolErrorText,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
|
|
@ -142,6 +143,10 @@ interface ChatSidebarProps {
|
|||
chatTabStates?: Record<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
isProcessing: boolean
|
||||
// Actively working (sessions runtime). When provided, drives the shimmer
|
||||
// instead of isProcessing so waiting on a permission/ask-human doesn't
|
||||
// render a "Thinking…" under the card.
|
||||
isThinking?: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
|
|
@ -155,6 +160,13 @@ interface ChatSidebarProps {
|
|||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
workDirByTab?: Record<string, string | null>
|
||||
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
|
||||
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
|
||||
/**
|
||||
* Set while a Rowboat-mode code session owns this pane: the chat is pinned to
|
||||
* the session, so the chat switcher / new-chat / history affordances hide.
|
||||
*/
|
||||
pinnedToCodeSession?: { title: string } | null
|
||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
|
|
@ -170,16 +182,16 @@ interface ChatSidebarProps {
|
|||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onSubmitRecording?: () => void | Promise<void>
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
inCall?: boolean
|
||||
onStartCall?: (preset: CallPreset) => void
|
||||
onEndCall?: () => void
|
||||
callAvailable?: boolean
|
||||
onComposioConnected?: (toolkitSlug: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +215,7 @@ export function ChatSidebar({
|
|||
chatTabStates = {},
|
||||
viewportAnchors = {},
|
||||
isProcessing,
|
||||
isThinking,
|
||||
isStopping,
|
||||
onStop,
|
||||
onSubmit,
|
||||
|
|
@ -216,6 +229,8 @@ export function ChatSidebar({
|
|||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
workDirByTab = {},
|
||||
codeSessionLocks = {},
|
||||
pinnedToCodeSession = null,
|
||||
onWorkDirChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
|
|
@ -231,15 +246,15 @@ export function ChatSidebar({
|
|||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
audioLevelsRef,
|
||||
onStartRecording,
|
||||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
inCall,
|
||||
onStartCall,
|
||||
onEndCall,
|
||||
callAvailable,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
|
|
@ -362,7 +377,14 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
// Session-first (new runtime); legacy runs fallback covers old
|
||||
// background tabs until stage 7 removes the runs runtime.
|
||||
let result: { success: boolean; error?: string }
|
||||
try {
|
||||
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
|
||||
} catch {
|
||||
result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success('Chat log saved')
|
||||
} else if (result.error) {
|
||||
|
|
@ -463,7 +485,7 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const errorText = getToolErrorText(item)
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
return (
|
||||
|
|
@ -555,17 +577,34 @@ export function ChatSidebar({
|
|||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
/>
|
||||
{pinnedToCodeSession ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-1.5 px-3 py-2 text-sm font-medium">
|
||||
<Pin className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{pinnedToCodeSession.title}</span>
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
Coding session
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
This chat is pinned to the coding session — leave the Code view to switch chats.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -646,9 +685,6 @@ export function ChatSidebar({
|
|||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
recentRuns={recentRuns}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
onPickPrompt={setLocalPresetMessage}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -700,7 +736,7 @@ export function ChatSidebar({
|
|||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && (isThinking ?? isProcessing)}
|
||||
response={response}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -717,7 +753,7 @@ export function ChatSidebar({
|
|||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && (isThinking ?? isProcessing)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -729,7 +765,7 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
{isActive && (isThinking ?? isProcessing) && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
|
|
@ -779,18 +815,19 @@ export function ChatSidebar({
|
|||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
workDir={workDirByTab[tab.id] ?? null}
|
||||
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
|
||||
codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
audioLevelsRef={audioLevelsRef}
|
||||
onStartRecording={isActive ? onStartRecording : undefined}
|
||||
onSubmitRecording={isActive ? onSubmitRecording : undefined}
|
||||
onCancelRecording={isActive ? onCancelRecording : undefined}
|
||||
voiceAvailable={isActive && voiceAvailable}
|
||||
ttsAvailable={isActive && ttsAvailable}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMode={ttsMode}
|
||||
onToggleTts={isActive ? onToggleTts : undefined}
|
||||
onTtsModeChange={isActive ? onTtsModeChange : undefined}
|
||||
inCall={inCall}
|
||||
onStartCall={isActive ? onStartCall : undefined}
|
||||
onEndCall={isActive ? onEndCall : undefined}
|
||||
callAvailable={callAvailable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
134
apps/x/apps/renderer/src/components/code/cm.ts
Normal file
134
apps/x/apps/renderer/src/components/code/cm.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { EditorView, lineNumbers } from '@codemirror/view'
|
||||
import { EditorState, type Extension } from '@codemirror/state'
|
||||
import {
|
||||
HighlightStyle,
|
||||
LanguageDescription,
|
||||
bracketMatching,
|
||||
syntaxHighlighting,
|
||||
defaultHighlightStyle,
|
||||
} from '@codemirror/language'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
// Shared CodeMirror setup for the Code section's read-only viewers
|
||||
// (file viewer + diff viewer). Theming keys off the app's resolved theme
|
||||
// instead of pulling in a theme package.
|
||||
|
||||
const darkHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: '#e06c75' },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
|
||||
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' },
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
|
||||
{ tag: [tags.typeName, tags.className, tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: '#e5c07b' },
|
||||
{ tag: [tags.operator, tags.operatorKeyword, tags.url, tags.escape, tags.regexp, tags.link, tags.special(tags.string)], color: '#56b6c2' },
|
||||
{ tag: [tags.meta, tags.comment], color: '#7d8799', fontStyle: 'italic' },
|
||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
|
||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
|
||||
{ tag: tags.invalid, color: '#ffffff' },
|
||||
])
|
||||
|
||||
export function cmBaseExtensions(isDark: boolean): Extension[] {
|
||||
const bg = isDark ? '#0f1117' : '#ffffff'
|
||||
const panelBg = isDark ? '#151821' : '#f6f8fa'
|
||||
const text = isDark ? '#d4d4d8' : '#24292f'
|
||||
const muted = isDark ? '#7d8590' : '#6e7781'
|
||||
const border = isDark ? '#2f3542' : '#d0d7de'
|
||||
|
||||
return [
|
||||
lineNumbers(),
|
||||
bracketMatching(),
|
||||
syntaxHighlighting(isDark ? darkHighlight : defaultHighlightStyle, { fallback: true }),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
fontSize: '12px',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-editor': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: text,
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-line': {
|
||||
color: text,
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: panelBg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: muted,
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)',
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: isDark ? 'rgba(88, 166, 255, 0.32)' : 'rgba(9, 105, 218, 0.22)',
|
||||
},
|
||||
'.cm-panels, .cm-panel': {
|
||||
backgroundColor: panelBg,
|
||||
color: text,
|
||||
borderColor: border,
|
||||
},
|
||||
'.cm-mergeView': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
},
|
||||
'.cm-mergeViewEditors': {
|
||||
backgroundColor: bg,
|
||||
},
|
||||
'.cm-mergeView .cm-editor': {
|
||||
borderColor: border,
|
||||
},
|
||||
'.cm-changedLine': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.14)' : 'rgba(9, 105, 218, 0.08)',
|
||||
},
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: isDark ? 'rgba(248, 81, 73, 0.14)' : 'rgba(255, 235, 233, 0.95)',
|
||||
},
|
||||
'.cm-insertedLine, .cm-insertedChunk': {
|
||||
backgroundColor: isDark ? 'rgba(63, 185, 80, 0.14)' : 'rgba(234, 255, 234, 0.95)',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
// GitHub-style expander bar for folded unchanged regions (@codemirror/merge).
|
||||
'.cm-collapsedLines': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.15)' : 'rgba(9, 105, 218, 0.08)',
|
||||
backgroundImage: 'none',
|
||||
color: isDark ? '#79c0ff' : '#0969da',
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-collapsedLines:hover': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.25)' : 'rgba(9, 105, 218, 0.15)',
|
||||
},
|
||||
},
|
||||
{ dark: isDark },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// Resolve a language extension from the filename (lazy-loaded; Vite splits
|
||||
// each language into its own chunk).
|
||||
export async function cmLanguageFor(filename: string): Promise<Extension | null> {
|
||||
const desc = LanguageDescription.matchFilename(languages, filename)
|
||||
if (!desc) return null
|
||||
try {
|
||||
return await desc.load()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
|
||||
|
||||
// Model + effort choices for a coding agent, discovered live from the engine
|
||||
// (the same list `/model` shows) via the main process, which caches per agent.
|
||||
// We memoize the in-flight/resolved promise per agent here too so reopening the
|
||||
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
|
||||
// just falls back to the engine default.
|
||||
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
|
||||
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
|
||||
|
||||
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
|
||||
let pending = cache.get(agent)
|
||||
if (!pending) {
|
||||
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
|
||||
cache.set(agent, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
// Always offer a Default fallback even before options load (or if discovery fails).
|
||||
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
|
||||
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
|
||||
}
|
||||
|
||||
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
|
||||
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
|
||||
}
|
||||
496
apps/x/apps/renderer/src/components/code/code-chat.tsx
Normal file
496
apps/x/apps/renderer/src/components/code/code-chat.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { useEffect, useRef, useState, type DragEvent, type MutableRefObject } from 'react'
|
||||
import { ArrowUp, FileText, Loader2, LoaderIcon, Mic, Plus, Square, Terminal, X } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, getToolDisplayName, 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>
|
||||
)
|
||||
}
|
||||
404
apps/x/apps/renderer/src/components/code/code-view.tsx
Normal file
404
apps/x/apps/renderer/src/components/code/code-view.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useCodeSessions } from './use-code-sessions'
|
||||
import { SessionRail } from './session-rail'
|
||||
import { NewSessionDialog } from './new-session-dialog'
|
||||
import { WorkspacePane } from './workspace-pane'
|
||||
import { TerminalPane } from './terminal-pane'
|
||||
|
||||
const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
|
||||
const TERMINAL_MIN_HEIGHT = 120
|
||||
const TERMINAL_MAX_HEIGHT = 600
|
||||
|
||||
// Remember which session was open so leaving the Code section (which unmounts
|
||||
// this view) and coming back restores the selection — and with it the chat
|
||||
// output in the right pane — instead of dropping back to the empty state.
|
||||
const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session'
|
||||
|
||||
function readStoredSelectedSessionId(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null
|
||||
}
|
||||
|
||||
function readStoredTerminalHeight(): number {
|
||||
if (typeof window === 'undefined') return 240
|
||||
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
|
||||
if (!Number.isFinite(raw) || raw <= 0) return 240
|
||||
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, raw))
|
||||
}
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask every time',
|
||||
'auto-approve-reads': 'Auto-approve reads',
|
||||
yolo: 'Auto-approve everything',
|
||||
}
|
||||
const POLICY_HEADER_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask',
|
||||
'auto-approve-reads': 'Auto reads',
|
||||
yolo: 'Auto all',
|
||||
}
|
||||
|
||||
export interface ActiveCodeSession {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
}
|
||||
|
||||
// The Code section's middle pane: session rail + workspace (diffs/files).
|
||||
// The conversation lives in the RIGHT pane — the assistant chat bound to the
|
||||
// session's run when Rowboat drives, or the direct-drive chat otherwise.
|
||||
// App.tsx learns which via onSessionSelected and renders the right pane.
|
||||
export function CodeView({
|
||||
onSessionSelected,
|
||||
openDiffPath,
|
||||
onDiffOpened,
|
||||
}: {
|
||||
onSessionSelected?: (active: ActiveCodeSession | null) => void
|
||||
// A file path the chat asked to review (clicking a changed file in a tool call).
|
||||
openDiffPath?: string | null
|
||||
onDiffOpened?: () => void
|
||||
}) {
|
||||
const { projects, sessions, statusOf, refresh } = useCodeSessions()
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(readStoredSelectedSessionId)
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalHeight, setTerminalHeight] = useState(readStoredTerminalHeight)
|
||||
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
|
||||
}, [terminalHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId)
|
||||
else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY)
|
||||
}, [selectedSessionId])
|
||||
|
||||
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragStateRef.current
|
||||
if (!drag) return
|
||||
// Terminal sits at the bottom: dragging up grows it.
|
||||
const next = drag.startHeight + (drag.startY - event.clientY)
|
||||
setTerminalHeight(Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, next)))
|
||||
}
|
||||
const onUp = () => {
|
||||
dragStateRef.current = null
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}, [terminalHeight])
|
||||
|
||||
const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null
|
||||
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
|
||||
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
|
||||
|
||||
// Live model/effort choices for the selected session's agent, for the header
|
||||
// pickers. Discovered from the engine and cached, so this is cheap to re-run.
|
||||
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
|
||||
const selectedAgent = selectedSession?.agent
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return }
|
||||
let cancelled = false
|
||||
void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) })
|
||||
return () => { cancelled = true }
|
||||
}, [selectedAgent])
|
||||
|
||||
// Tell App which session (and status) owns the right-hand chat pane.
|
||||
useEffect(() => {
|
||||
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
|
||||
}, [selectedSession, selectedStatus, onSessionSelected])
|
||||
|
||||
// Leaving the Code section unmounts this view — release the right pane.
|
||||
useEffect(() => {
|
||||
return () => onSessionSelected?.(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleAddProject = useCallback(async () => {
|
||||
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose a project folder' })
|
||||
const dir = res.path
|
||||
if (!dir) return
|
||||
try {
|
||||
const added = await window.ipc.invoke('codeProject:add', { path: dir })
|
||||
await refresh()
|
||||
setNewSessionProjectId(added.project.id)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add project')
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const handleRemoveProject = useCallback(async (projectId: string) => {
|
||||
await window.ipc.invoke('codeProject:remove', { projectId })
|
||||
await refresh()
|
||||
}, [refresh])
|
||||
|
||||
const handleSessionCreated = useCallback(async (session: CodeSession) => {
|
||||
await refresh()
|
||||
setSelectedSessionId(session.id)
|
||||
}, [refresh])
|
||||
|
||||
const handleDeleteSession = useCallback(async (session: CodeSession, removeWorktree: boolean) => {
|
||||
try {
|
||||
await window.ipc.invoke('codeSession:delete', {
|
||||
sessionId: session.id,
|
||||
removeWorktree,
|
||||
deleteBranch: removeWorktree,
|
||||
})
|
||||
if (selectedSessionId === session.id) setSelectedSessionId(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete session')
|
||||
}
|
||||
}, [refresh, selectedSessionId])
|
||||
|
||||
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex'; agentModel?: string; agentEffort?: string }) => {
|
||||
if (!selectedSessionId) return
|
||||
try {
|
||||
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update session')
|
||||
}
|
||||
}, [refresh, selectedSessionId])
|
||||
|
||||
const busy = selectedStatus === 'working' || selectedStatus === 'needs-you'
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Session rail */}
|
||||
<div className="w-64 shrink-0 border-r">
|
||||
<SessionRail
|
||||
projects={projects}
|
||||
sessions={sessions}
|
||||
statusOf={statusOf}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onSelectSession={setSelectedSessionId}
|
||||
onAddProject={() => void handleAddProject()}
|
||||
onRemoveProject={(id) => void handleRemoveProject(id)}
|
||||
onNewSession={setNewSessionProjectId}
|
||||
onDeleteSession={setDeleteTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Workspace: session header + diffs/files. The chat is in the right pane. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{selectedSession ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2 border-b px-4 py-2.5">
|
||||
<div className="min-w-64 flex-[1_1_360px]">
|
||||
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="shrink-0 whitespace-nowrap">{AGENT_LABEL[selectedSession.agent]}</span>
|
||||
<span className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 max-w-full flex-1 truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
|
||||
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
|
||||
<span className="flex min-w-0 max-w-72 shrink items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
|
||||
<GitBranch className="size-3" />
|
||||
<span className="truncate">{selectedSession.worktree.branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Coding agent model"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
|
||||
{m.label}
|
||||
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Reasoning effort"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.efforts, selectedSession.agentEffort)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{withDefault(modelOpts.efforts).map((e) => (
|
||||
<DropdownMenuItem key={e.value} onClick={() => void handleUpdateSession({ agentEffort: e.value })}>
|
||||
{e.label}
|
||||
{(selectedSession.agentEffort ?? 'default') === e.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title={POLICY_LABEL[selectedSession.policy]}
|
||||
>
|
||||
<span className="whitespace-nowrap">{POLICY_HEADER_LABEL[selectedSession.policy]}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
|
||||
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
|
||||
{POLICY_LABEL[policy]}
|
||||
{selectedSession.policy === policy && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bot className="size-3.5" />
|
||||
<span className="whitespace-nowrap">Rowboat drives</span>
|
||||
<Switch
|
||||
checked={selectedSession.mode === 'rowboat'}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkspacePane
|
||||
session={selectedSession}
|
||||
status={selectedStatus}
|
||||
openDiffPath={openDiffPath ?? null}
|
||||
onDiffOpened={() => onDiffOpened?.()}
|
||||
onSessionChanged={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Embedded terminal — a real shell in the session's directory
|
||||
(worktree included). The PTY lives in the main process and
|
||||
survives collapsing this panel. */}
|
||||
<div className="shrink-0 border-t">
|
||||
{terminalOpen && (
|
||||
<div
|
||||
onMouseDown={handleTerminalDragStart}
|
||||
className="h-1 cursor-row-resize bg-transparent transition-colors hover:bg-sidebar-border"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTerminalOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<TerminalIcon className="size-3.5" />
|
||||
<span className="font-medium">Terminal</span>
|
||||
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px]">worktree</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
|
||||
</button>
|
||||
{terminalOpen && (
|
||||
<div className="bg-background pb-3 dark:bg-black" style={{ height: terminalHeight + 12 }}>
|
||||
<div className="h-full min-h-0">
|
||||
<TerminalPane
|
||||
key={selectedSession.id}
|
||||
terminalId={selectedSession.id}
|
||||
cwd={selectedSession.cwd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<Code2 className="size-10 text-muted-foreground/40" />
|
||||
<div className="text-sm font-medium">Code with agents</div>
|
||||
<p className="max-w-sm px-6 text-xs text-muted-foreground">
|
||||
Run Claude Code or Codex on your projects — let Rowboat drive them, or talk to them
|
||||
directly. The conversation happens in the chat pane on the right; changes and files
|
||||
show here.
|
||||
</p>
|
||||
{projects.length === 0 ? (
|
||||
<Button size="sm" onClick={() => void handleAddProject()}>Add a project to get started</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Pick a session on the left, or create a new one.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NewSessionDialog
|
||||
projectRow={newSessionProject}
|
||||
open={newSessionProjectId !== null}
|
||||
onOpenChange={(open) => { if (!open) setNewSessionProjectId(null) }}
|
||||
onCreated={(session) => void handleSessionCreated(session)}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The conversation history will be deleted.
|
||||
{deleteTarget?.worktree && !deleteTarget.worktree.removedAt
|
||||
? ' Its worktree and branch will be removed too — merge back first if you want to keep the changes.'
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteTarget) void handleDeleteSession(deleteTarget, true)
|
||||
setDeleteTarget(null)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
apps/x/apps/renderer/src/components/code/diff-viewer.tsx
Normal file
121
apps/x/apps/renderer/src/components/code/diff-viewer.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MergeView, unifiedMergeView } from '@codemirror/merge'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Columns2, FoldVertical, Rows2, UnfoldVertical, X } from 'lucide-react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cmBaseExtensions, cmLanguageFor } from './cm'
|
||||
|
||||
// Read-only diff of one file's working-tree changes vs HEAD, side-by-side or
|
||||
// unified. Content comes from codeSession:fileDiff (old = git show HEAD:path,
|
||||
// new = disk).
|
||||
export function DiffViewer({
|
||||
sessionId,
|
||||
path,
|
||||
onClose,
|
||||
}: {
|
||||
sessionId: string
|
||||
path: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mode, setMode] = useState<'split' | 'unified'>('split')
|
||||
// GitHub-style: unchanged regions fold into "⋯ N lines" bars (each clickable
|
||||
// to reveal); "Expand all" rebuilds the view with nothing collapsed.
|
||||
const [collapseUnchanged, setCollapseUnchanged] = useState(true)
|
||||
const [diff, setDiff] = useState<{ oldText: string; newText: string; isBinary: boolean; tooLarge: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setDiff(null)
|
||||
setError(null)
|
||||
window.ipc.invoke('codeSession:fileDiff', { sessionId, path })
|
||||
.then((res) => { if (!cancelled) setDiff(res) })
|
||||
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load diff') })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, path])
|
||||
|
||||
useEffect(() => {
|
||||
const parent = containerRef.current
|
||||
if (!parent || !diff || diff.isBinary || diff.tooLarge) return
|
||||
let view: MergeView | EditorView | null = null
|
||||
let cancelled = false
|
||||
|
||||
void cmLanguageFor(path).then((language) => {
|
||||
if (cancelled || !containerRef.current) return
|
||||
const extensions = [...cmBaseExtensions(isDark), ...(language ? [language] : [])]
|
||||
// Same context margins GitHub uses: keep a few lines around each hunk,
|
||||
// only fold stretches long enough to be worth hiding.
|
||||
const collapse = collapseUnchanged ? { margin: 3, minSize: 6 } : undefined
|
||||
if (mode === 'split') {
|
||||
view = new MergeView({
|
||||
a: { doc: diff.oldText, extensions },
|
||||
b: { doc: diff.newText, extensions },
|
||||
parent,
|
||||
gutter: true,
|
||||
...(collapse ? { collapseUnchanged: collapse } : {}),
|
||||
})
|
||||
} else {
|
||||
view = new EditorView({
|
||||
doc: diff.newText,
|
||||
extensions: [
|
||||
...extensions,
|
||||
unifiedMergeView({
|
||||
original: diff.oldText,
|
||||
mergeControls: false,
|
||||
...(collapse ? { collapseUnchanged: collapse } : {}),
|
||||
}),
|
||||
],
|
||||
parent,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
view?.destroy()
|
||||
}
|
||||
}, [diff, mode, isDark, path, collapseUnchanged])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90" title={path}>{path}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setCollapseUnchanged((c) => !c)}
|
||||
title={collapseUnchanged ? 'Show the whole file' : 'Collapse unchanged regions'}
|
||||
>
|
||||
{collapseUnchanged ? <UnfoldVertical className="size-3.5" /> : <FoldVertical className="size-3.5" />}
|
||||
{collapseUnchanged ? 'Expand all' : 'Collapse'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setMode((m) => (m === 'split' ? 'unified' : 'split'))}
|
||||
title={mode === 'split' ? 'Switch to unified view' : 'Switch to side-by-side view'}
|
||||
>
|
||||
{mode === 'split' ? <Rows2 className="size-3.5" /> : <Columns2 className="size-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close diff">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
|
||||
{!error && !diff && <div className="p-4 text-sm text-muted-foreground">Loading diff…</div>}
|
||||
{diff?.isBinary && <div className="p-4 text-sm text-muted-foreground">Binary file — no text diff.</div>}
|
||||
{diff?.tooLarge && <div className="p-4 text-sm text-muted-foreground">File too large to diff here.</div>}
|
||||
{diff && !diff.isBinary && !diff.tooLarge && (
|
||||
<div ref={containerRef} className="h-full [&_.cm-mergeView]:h-full [&_.cm-editor]:h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
apps/x/apps/renderer/src/components/code/file-tree.tsx
Normal file
101
apps/x/apps/renderer/src/components/code/file-tree.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreeEntry {
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
}
|
||||
|
||||
// Lazy file tree over codeSession:readdir — one directory level per request,
|
||||
// so big folders (node_modules) cost nothing until expanded.
|
||||
export function CodeFileTree({
|
||||
sessionId,
|
||||
selectedPath,
|
||||
onSelectFile,
|
||||
}: {
|
||||
sessionId: string
|
||||
selectedPath: string | null
|
||||
onSelectFile: (relPath: string) => void
|
||||
}) {
|
||||
const [childrenByDir, setChildrenByDir] = useState<Record<string, TreeEntry[]>>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadDir = useCallback(async (relPath: string) => {
|
||||
try {
|
||||
const res = await window.ipc.invoke('codeSession:readdir', { sessionId, relPath })
|
||||
setChildrenByDir((prev) => ({ ...prev, [relPath]: res.entries }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to read directory')
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
setChildrenByDir({})
|
||||
setExpanded(new Set())
|
||||
setError(null)
|
||||
void loadDir('.')
|
||||
}, [loadDir])
|
||||
|
||||
const toggleDir = (relPath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relPath)) {
|
||||
next.delete(relPath)
|
||||
} else {
|
||||
next.add(relPath)
|
||||
if (!childrenByDir[relPath]) void loadDir(relPath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const renderDir = (relPath: string, depth: number) => {
|
||||
const entries = childrenByDir[relPath]
|
||||
if (!entries) {
|
||||
return <div className="px-2 py-1 text-xs text-muted-foreground" style={{ paddingLeft: depth * 12 + 8 }}>Loading…</div>
|
||||
}
|
||||
return entries.map((entry) => {
|
||||
const childPath = relPath === '.' ? entry.name : `${relPath}/${entry.name}`
|
||||
if (entry.kind === 'dir') {
|
||||
const isOpen = expanded.has(childPath)
|
||||
return (
|
||||
<div key={childPath}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDir(childPath)}
|
||||
className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted"
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
>
|
||||
{isOpen ? <ChevronDown className="size-3 shrink-0" /> : <ChevronRight className="size-3 shrink-0" />}
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
{isOpen && renderDir(childPath, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={childPath}
|
||||
type="button"
|
||||
onClick={() => onSelectFile(childPath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted',
|
||||
selectedPath === childPath && 'bg-muted font-medium',
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 22 }}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-3 text-xs text-destructive">{error}</div>
|
||||
}
|
||||
return <div className="overflow-auto py-1">{renderDir('.', 0)}</div>
|
||||
}
|
||||
70
apps/x/apps/renderer/src/components/code/file-viewer.tsx
Normal file
70
apps/x/apps/renderer/src/components/code/file-viewer.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cmBaseExtensions, cmLanguageFor } from './cm'
|
||||
|
||||
// Read-only, syntax-highlighted view of one file in the session directory.
|
||||
export function CodeFileViewer({
|
||||
sessionId,
|
||||
path,
|
||||
onClose,
|
||||
}: {
|
||||
sessionId: string
|
||||
path: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [file, setFile] = useState<{ content: string; isBinary: boolean; tooLarge: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFile(null)
|
||||
setError(null)
|
||||
window.ipc.invoke('codeSession:readFile', { sessionId, relPath: path })
|
||||
.then((res) => { if (!cancelled) setFile(res) })
|
||||
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to read file') })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, path])
|
||||
|
||||
useEffect(() => {
|
||||
const parent = containerRef.current
|
||||
if (!parent || !file || file.isBinary || file.tooLarge) return
|
||||
let view: EditorView | null = null
|
||||
let cancelled = false
|
||||
void cmLanguageFor(path).then((language) => {
|
||||
if (cancelled || !containerRef.current) return
|
||||
view = new EditorView({
|
||||
doc: file.content,
|
||||
extensions: [...cmBaseExtensions(isDark), ...(language ? [language] : [])],
|
||||
parent,
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
view?.destroy()
|
||||
}
|
||||
}, [file, isDark, path])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90" title={path}>{path}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close file">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
|
||||
{!error && !file && <div className="p-4 text-sm text-muted-foreground">Loading…</div>}
|
||||
{file?.isBinary && <div className="p-4 text-sm text-muted-foreground">Binary file.</div>}
|
||||
{file?.tooLarge && <div className="p-4 text-sm text-muted-foreground">File too large to preview.</div>}
|
||||
{file && !file.isBinary && !file.tooLarge && <div ref={containerRef} className="h-full [&_.cm-editor]:h-full" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
372
apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Normal file
372
apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
|
||||
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ProjectRow } from './use-code-sessions'
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type ModelOption = { provider: string; model: string }
|
||||
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask every time',
|
||||
'auto-approve-reads': 'Auto-approve reads',
|
||||
yolo: 'Auto-approve everything (YOLO)',
|
||||
}
|
||||
|
||||
// Models the user can pick for Rowboat-mode turns — mirrors the chat
|
||||
// composer's loading: gateway list when signed in, models.json otherwise.
|
||||
async function loadModelOptions(): Promise<ModelOption[]> {
|
||||
try {
|
||||
const oauth = await window.ipc.invoke('oauth:getState', null)
|
||||
const connected = oauth.config?.rowboat?.connected ?? false
|
||||
if (connected) {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
|
||||
?.find((p) => p.id === 'rowboat')
|
||||
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ModelOption[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) models.push({ provider: flavor, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function NewSessionDialog({
|
||||
projectRow,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
projectRow: ProjectRow | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (session: CodeSession) => void
|
||||
}) {
|
||||
const [agentStatus, setAgentStatus] = useState<{ claude: AgentStatus; codex: AgentStatus } | null>(null)
|
||||
const [agent, setAgent] = useState<CodingAgent>('claude')
|
||||
// Direct drive by default; Rowboat orchestration remains an opt-in per session.
|
||||
const [mode, setMode] = useState<CodeSessionMode>('direct')
|
||||
const [policy, setPolicy] = useState<ApprovalPolicy>('auto-approve-reads')
|
||||
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
|
||||
const [title, setTitle] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
|
||||
// 'default' = let the backend use the configured default model.
|
||||
const [modelKey, setModelKey] = useState('default')
|
||||
// The coding agent's own model + reasoning effort. 'default' leaves the
|
||||
// engine default. Choices are discovered live per agent (see effect below).
|
||||
const [agentModel, setAgentModel] = useState('default')
|
||||
const [agentEffort, setAgentEffort] = useState('default')
|
||||
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
|
||||
|
||||
const git = projectRow?.git
|
||||
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTitle('')
|
||||
setCreating(false)
|
||||
setIsolation('in-repo')
|
||||
setMode('direct')
|
||||
setModelKey('default')
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
void loadModelOptions().then(setModelOptions)
|
||||
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
|
||||
setAgentStatus(status)
|
||||
// Default to whichever agent is actually ready.
|
||||
const claudeReady = status.claude.installed && status.claude.signedIn
|
||||
const codexReady = status.codex.installed && status.codex.signedIn
|
||||
if (!claudeReady && codexReady) setAgent('codex')
|
||||
else setAgent('claude')
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// Model/effort choices are per-agent (and the saved value from one agent is
|
||||
// meaningless for the other), so reset to defaults and (re)load the live list
|
||||
// whenever the agent changes.
|
||||
useEffect(() => {
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
setModelOpts({ models: [], efforts: [] })
|
||||
let cancelled = false
|
||||
void fetchCodeAgentOptions(agent).then((opts) => { if (!cancelled) setModelOpts(opts) })
|
||||
return () => { cancelled = true }
|
||||
}, [agent])
|
||||
|
||||
const agentReady = (a: CodingAgent): boolean => {
|
||||
if (!agentStatus) return true
|
||||
const s = agentStatus[a]
|
||||
return s.installed && s.signedIn
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!projectRow) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const picked = modelKey !== 'default'
|
||||
? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey)
|
||||
: undefined
|
||||
const res = await window.ipc.invoke('codeSession:create', {
|
||||
projectId: projectRow.project.id,
|
||||
title: title.trim() || undefined,
|
||||
agent,
|
||||
mode,
|
||||
policy,
|
||||
isolation,
|
||||
...(picked ? { model: picked.model, provider: picked.provider } : {}),
|
||||
...(agentModel !== 'default' ? { agentModel } : {}),
|
||||
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCreated(res.session)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create session')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New coding session</DialogTitle>
|
||||
<DialogDescription>
|
||||
{projectRow ? <span className="font-mono text-xs">{projectRow.project.path}</span> : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Name (optional)</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Fix flaky auth tests"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Coding agent</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['claude', 'codex'] as const).map((a) => {
|
||||
const ready = agentReady(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setAgent(a)}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
agent === a ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
!ready && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{a === 'claude' ? 'Claude Code' : 'Codex'}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{ready ? 'Ready' : agentStatus?.[a]?.installed ? 'Not signed in' : 'Enable in Settings'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Who drives</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('rowboat')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
mode === 'rowboat' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Bot className="size-3.5" />
|
||||
Rowboat
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Full assistant chat — Rowboat plans, runs the agent, and can use your knowledge.
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('direct')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
mode === 'direct' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Terminal className="size-3.5" />
|
||||
Direct
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Talk straight to the coding agent — no assistant in between.
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Where it works</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsolation('in-repo')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
isolation === 'in-repo' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">Directly in the project</div>
|
||||
<div className="text-[11px] text-muted-foreground">Changes land in your working tree.</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!worktreeAvailable}
|
||||
onClick={() => setIsolation('worktree')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
isolation === 'worktree' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
!worktreeAvailable && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<GitBranch className="size-3.5" />
|
||||
Isolated worktree
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{worktreeAvailable
|
||||
? 'Works on its own branch — safe to run sessions in parallel; merge back when done.'
|
||||
: 'Needs a git repository with at least one commit.'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Approvals</label>
|
||||
<Select value={policy} onValueChange={(v) => setPolicy(v as ApprovalPolicy)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((p) => (
|
||||
<SelectItem key={p} value={p}>{POLICY_LABEL[p]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How the coding agent's file edits and commands get approved — applies in both modes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* The coding agent's own model + reasoning effort, discovered live
|
||||
from the engine and applied to the ACP session each turn (so they
|
||||
stay editable from the session header later). Effort is a separate
|
||||
axis only for Claude; Codex folds it into the model id. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={agentModel} onValueChange={setAgentModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Effort</label>
|
||||
<Select value={agentEffort} onValueChange={setAgentEffort}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.efforts).map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The model only powers Rowboat's own turns; the coding agent uses its
|
||||
own configured model, so hide this entirely for direct sessions. */}
|
||||
{mode === 'rowboat' && modelOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={modelKey} onValueChange={setModelKey}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default model</SelectItem>
|
||||
{modelOptions.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Used when Rowboat drives. Fixed once the session is created, like any chat.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={() => void handleCreate()} disabled={creating || !projectRow || !agentReady(agent)}>
|
||||
{creating && <Loader2 className="size-4 animate-spin" />}
|
||||
Create session
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Mirror of ChatSidebar's resize behavior for the direct-mode code chat pane:
|
||||
// same bounds, same drag handle, and the SAME persisted width key — so the
|
||||
// assistant pane and the direct pane stay the same size as the user switches
|
||||
// between session modes.
|
||||
const MIN_WIDTH = 360
|
||||
const MAX_WIDTH = 1600
|
||||
const MIN_MAIN_PANE_WIDTH = 420
|
||||
const MIN_MAIN_PANE_RATIO = 0.3
|
||||
const RIGHT_PANE_WIDTH_STORAGE_KEY = 'x:right-pane-width'
|
||||
|
||||
function clampPaneWidth(width: number, maxWidth: number = MAX_WIDTH): number {
|
||||
const boundedMax = Math.max(0, Math.min(MAX_WIDTH, maxWidth))
|
||||
const boundedMin = Math.min(MIN_WIDTH, boundedMax)
|
||||
return Math.min(boundedMax, Math.max(boundedMin, width))
|
||||
}
|
||||
|
||||
function readStoredWidth(defaultWidth: number): number {
|
||||
const fallback = clampPaneWidth(defaultWidth)
|
||||
if (typeof window === 'undefined') return fallback
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RIGHT_PANE_WIDTH_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return clampPaneWidth(parsed)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function ResizableRightPane({
|
||||
defaultWidth = 460,
|
||||
className,
|
||||
children,
|
||||
onActivate,
|
||||
}: {
|
||||
defaultWidth?: number
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
/** Fired on any mouse-down inside the pane (keyboard-shortcut focus tracking). */
|
||||
onActivate?: () => void
|
||||
}) {
|
||||
const paneRef = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(() => readStoredWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
|
||||
// Never let the pane squeeze the main content below a usable width.
|
||||
const getMaxAllowedWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') return MAX_WIDTH
|
||||
const paneElement = paneRef.current
|
||||
const splitContainer = paneElement?.parentElement
|
||||
const mainPane = splitContainer?.querySelector<HTMLElement>('[data-slot="sidebar-inset"]')
|
||||
const paneWidth = paneElement?.getBoundingClientRect().width ?? 0
|
||||
const mainPaneWidth = mainPane?.getBoundingClientRect().width ?? 0
|
||||
const splitWidth = paneWidth + mainPaneWidth
|
||||
const fallbackWidth = splitContainer?.clientWidth ?? window.innerWidth
|
||||
const availableSplitWidth = splitWidth > 0 ? splitWidth : fallbackWidth
|
||||
const minMainPaneWidth = Math.min(
|
||||
availableSplitWidth,
|
||||
Math.max(MIN_MAIN_PANE_WIDTH, Math.floor(availableSplitWidth * MIN_MAIN_PANE_RATIO)),
|
||||
)
|
||||
return Math.max(0, availableSplitWidth - minMainPaneWidth)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(RIGHT_PANE_WIDTH_STORAGE_KEY, String(width))
|
||||
} catch {
|
||||
// keep in-memory width on persistence failure
|
||||
}
|
||||
}, [width])
|
||||
|
||||
useEffect(() => {
|
||||
const clampToAvailableWidth = () => {
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth((prev) => clampPaneWidth(prev, maxAllowedWidth))
|
||||
}
|
||||
clampToAvailableWidth()
|
||||
window.addEventListener('resize', clampToAvailableWidth)
|
||||
return () => window.removeEventListener('resize', clampToAvailableWidth)
|
||||
}, [getMaxAllowedWidth])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = width
|
||||
setIsResizing(true)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
// Pane sits on the right: dragging left grows it.
|
||||
const delta = startXRef.current - event.clientX
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||
}
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width, getMaxAllowedWidth])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
onMouseDownCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-h-0 min-w-0 shrink-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
className,
|
||||
)}
|
||||
style={{ width, flex: '0 0 auto' }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||
'hover:after:bg-sidebar-border',
|
||||
isResizing && 'after:bg-primary',
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
apps/x/apps/renderer/src/components/code/session-rail.tsx
Normal file
179
apps/x/apps/renderer/src/components/code/session-rail.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { FolderGit2, FolderPlus, MoreHorizontal, Plus, Trash2 } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { ProjectRow } from './use-code-sessions'
|
||||
|
||||
function StatusDot({ status }: { status: CodeSessionStatus }) {
|
||||
if (status === 'needs-you') {
|
||||
return <span className="size-2 shrink-0 animate-pulse rounded-full bg-amber-500" title="Needs your attention" />
|
||||
}
|
||||
if (status === 'working') {
|
||||
return <span className="size-2 shrink-0 animate-pulse rounded-full bg-blue-500" title="Working" />
|
||||
}
|
||||
return <span className="size-2 shrink-0 rounded-full bg-muted-foreground/30" title="Idle" />
|
||||
}
|
||||
|
||||
const AGENT_SHORT: Record<string, string> = { claude: 'Claude', codex: 'Codex' }
|
||||
|
||||
// Left rail: registered projects with their sessions, attention-first.
|
||||
export function SessionRail({
|
||||
projects,
|
||||
sessions,
|
||||
statusOf,
|
||||
selectedSessionId,
|
||||
onSelectSession,
|
||||
onAddProject,
|
||||
onRemoveProject,
|
||||
onNewSession,
|
||||
onDeleteSession,
|
||||
}: {
|
||||
projects: ProjectRow[]
|
||||
sessions: CodeSession[]
|
||||
statusOf: (sessionId: string) => CodeSessionStatus
|
||||
selectedSessionId: string | null
|
||||
onSelectSession: (sessionId: string) => void
|
||||
onAddProject: () => void
|
||||
onRemoveProject: (projectId: string) => void
|
||||
onNewSession: (projectId: string) => void
|
||||
onDeleteSession: (session: CodeSession) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Projects</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onAddProject}>
|
||||
<FolderPlus className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a project folder</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-2 pb-2">
|
||||
{projects.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-3 py-10 text-center">
|
||||
<FolderGit2 className="size-8 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a project folder to start running coding agents on it.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onAddProject}>
|
||||
<FolderPlus className="size-3.5" />
|
||||
Add project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{projects.map(({ project }) => {
|
||||
const projectSessions = sessions.filter((s) => s.projectId === project.id)
|
||||
return (
|
||||
<div key={project.id} className="mb-3">
|
||||
<div className="group flex items-center gap-1.5 px-1 py-1">
|
||||
{/* Deliberate hover delay — the full path is reference info,
|
||||
not something that should pop up on a passing cursor. */}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<FolderGit2 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{project.name}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[420px] break-all font-mono text-xs">
|
||||
{project.path}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => onNewSession(project.id)}
|
||||
title="New session"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => onRemoveProject(project.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{projectSessions.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession(project.id)}
|
||||
className="ml-5 flex items-center gap-1.5 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
New session
|
||||
</button>
|
||||
) : (
|
||||
projectSessions.map((session) => {
|
||||
const status = statusOf(session.id)
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'group ml-3 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5',
|
||||
selectedSessionId === session.id ? 'bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<StatusDot status={status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs">{session.title}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{AGENT_SHORT[session.agent]}
|
||||
{session.mode === 'rowboat' ? ' · Rowboat drives' : ''}
|
||||
{session.worktree && !session.worktree.removedAt ? ' · worktree' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 shrink-0 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={() => onDeleteSession(session)}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete session
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
116
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
||||
// xterm color schemes tuned to the app's light/dark backgrounds.
|
||||
const DARK_THEME = {
|
||||
background: '#000000',
|
||||
foreground: '#d4d4d8',
|
||||
cursor: '#d4d4d8',
|
||||
selectionBackground: 'rgba(120, 140, 255, 0.3)',
|
||||
}
|
||||
const LIGHT_THEME = {
|
||||
background: '#ffffff',
|
||||
foreground: '#27272a',
|
||||
cursor: '#27272a',
|
||||
selectionBackground: 'rgba(60, 90, 220, 0.2)',
|
||||
}
|
||||
|
||||
// One embedded terminal view, attached to a per-session PTY in the main
|
||||
// process. The PTY outlives this component (collapse/switch just detaches);
|
||||
// on mount we re-attach and repaint from the backlog the main process keeps.
|
||||
export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: string }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const term = new Terminal({
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
theme: resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME,
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(container)
|
||||
fit.fit()
|
||||
termRef.current = term
|
||||
|
||||
let disposed = false
|
||||
|
||||
// Attach (or spawn) the PTY at the current size, then repaint history.
|
||||
void window.ipc.invoke('terminal:ensure', {
|
||||
id: terminalId,
|
||||
cwd,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}).then(({ backlog }) => {
|
||||
if (disposed) return
|
||||
if (backlog) term.write(backlog)
|
||||
term.focus()
|
||||
})
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
void window.ipc.invoke('terminal:input', { id: terminalId, data })
|
||||
})
|
||||
|
||||
const offData = window.ipc.on('terminal:data', (payload) => {
|
||||
if (payload.id === terminalId) term.write(payload.data)
|
||||
})
|
||||
const offExit = window.ipc.on('terminal:exit', (payload) => {
|
||||
if (payload.id !== terminalId) return
|
||||
term.write(`\r\n\x1b[2m[process exited with code ${payload.exitCode} — press Enter to restart]\x1b[0m\r\n`)
|
||||
})
|
||||
|
||||
// Restart the shell on Enter after it exited (ensure() respawns dead PTYs).
|
||||
const keyDisposable = term.onKey(({ domEvent }) => {
|
||||
if (domEvent.key !== 'Enter') return
|
||||
void window.ipc.invoke('terminal:ensure', {
|
||||
id: terminalId,
|
||||
cwd,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
})
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (container.clientHeight === 0) return
|
||||
fit.fit()
|
||||
void window.ipc.invoke('terminal:resize', { id: terminalId, cols: term.cols, rows: term.rows })
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
resizeObserver.disconnect()
|
||||
offData()
|
||||
offExit()
|
||||
dataDisposable.dispose()
|
||||
keyDisposable.dispose()
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
// The PTY is keyed by terminalId; cwd changes (worktree cleanup) respawn via ensure.
|
||||
}, [terminalId, cwd])
|
||||
|
||||
// Live theme switches restyle the existing terminal without a respawn.
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||
}, [resolvedTheme])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full overflow-hidden px-2 pt-1"
|
||||
style={{ backgroundColor: resolvedTheme === 'dark' ? '#000000' : '#ffffff' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
511
apps/x/apps/renderer/src/components/code/use-code-chat.ts
Normal file
511
apps/x/apps/renderer/src/components/code/use-code-chat.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type z from 'zod'
|
||||
import type { RunEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
|
||||
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
|
||||
import type { CodeSession } from '@x/shared/src/code-sessions.js'
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ErrorMessage,
|
||||
type ToolCall,
|
||||
normalizeToolInput,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
||||
// A direct-drive coding turn: the structural ACP events (tool calls, plan,
|
||||
// resolved permissions) grouped under one turn id. The agent's prose is NOT
|
||||
// part of the turn — it streams via liveText and lands as an assistant
|
||||
// ChatMessage, so live rendering and JSONL replay converge on the same shape.
|
||||
export interface DirectTurn {
|
||||
kind: 'direct-turn'
|
||||
id: string
|
||||
events: CodeRunEvent[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type CodeChatItem = ChatMessage | ToolCall | ErrorMessage | DirectTurn
|
||||
|
||||
export const isDirectTurn = (item: CodeChatItem): item is DirectTurn =>
|
||||
'kind' in item && (item as DirectTurn).kind === 'direct-turn'
|
||||
|
||||
// Narrowing guards over the widened item union (the chat-conversation guards
|
||||
// only accept ConversationItem).
|
||||
export const isChatToolCall = (item: CodeChatItem): item is ToolCall => 'name' in item
|
||||
export const isChatErrorMessage = (item: CodeChatItem): item is ErrorMessage =>
|
||||
'kind' in item && (item as ErrorMessage).kind === 'error'
|
||||
export const isChatMessageItem = (item: CodeChatItem): item is ChatMessage => 'role' in item
|
||||
|
||||
export interface PendingCodePermission {
|
||||
requestId: string
|
||||
ask: PermissionAsk
|
||||
toolCallId: string
|
||||
}
|
||||
|
||||
const DIRECT_PREFIX = 'direct-'
|
||||
const STRUCTURAL_EVENTS = new Set(['tool_call', 'tool_call_update', 'plan', 'permission'])
|
||||
const COMPACTION_TITLE = 'Compacting context'
|
||||
const COMPACTION_STALLED_MS = 90_000
|
||||
|
||||
export type CompactionStatus = 'idle' | 'running' | 'stalled'
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
return (content as Array<{ type: string; text?: string }>)
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Conversation state for one coding session, fed by the run JSONL (history)
|
||||
// and the live runs:events stream. Handles both modes: direct turns arrive as
|
||||
// code-run-events with a `direct-` toolCallId; Rowboat turns arrive as the
|
||||
// usual LLM message/tool events (incl. code_agent_run blocks).
|
||||
export function useCodeChat(session: CodeSession | null) {
|
||||
const sessionId = session?.id ?? null
|
||||
const [items, setItems] = useState<CodeChatItem[]>([])
|
||||
const [liveText, setLiveText] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [compactionStatus, setCompactionStatus] = useState<CompactionStatus>('idle')
|
||||
const [contextUsage, setContextUsage] = useState<{ used: number; size: number } | null>(null)
|
||||
const [pendingPermission, setPendingPermission] = useState<PendingCodePermission | null>(null)
|
||||
// Rowboat-mode copilot gates, same as the main chat: pre-tool-call permission
|
||||
// requests and ask-human questions. Keyed by toolCallId.
|
||||
const [pendingToolPermissions, setPendingToolPermissions] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
|
||||
const [pendingAskHumans, setPendingAskHumans] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const seenMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
const compactionToolIdRef = useRef<string | null>(null)
|
||||
|
||||
const applyCodeRunEvent = useCallback((toolCallId: string, event: CodeRunEvent) => {
|
||||
if (toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
if (!STRUCTURAL_EVENTS.has(event.type)) return
|
||||
setItems((prev) => {
|
||||
const at = prev.findIndex((item) => isDirectTurn(item) && item.id === toolCallId)
|
||||
if (at >= 0) {
|
||||
const turn = prev[at] as DirectTurn
|
||||
const next = [...prev]
|
||||
next[at] = { ...turn, events: [...turn.events, event] }
|
||||
return next
|
||||
}
|
||||
return [...prev, { kind: 'direct-turn', id: toolCallId, events: [event], timestamp: Date.now() }]
|
||||
})
|
||||
return
|
||||
}
|
||||
// Rowboat mode: attach to the code_agent_run tool call block.
|
||||
setItems((prev) => prev.map((item) => {
|
||||
if (isChatToolCall(item) && item.id === toolCallId) {
|
||||
return { ...item, codeRunEvents: [...(item.codeRunEvents ?? []), event] }
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// Load history from the run log whenever the session changes.
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setCompactionStatus('idle')
|
||||
setContextUsage(null)
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setCompactionStatus('idle')
|
||||
setContextUsage(null)
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
setPendingToolPermissions(new Map())
|
||||
setPendingAskHumans(new Map())
|
||||
seenMessageIdsRef.current = new Set()
|
||||
|
||||
void window.ipc.invoke('runs:fetch', { runId: sessionId }).then((run) => {
|
||||
if (cancelled) return
|
||||
const loaded: CodeChatItem[] = []
|
||||
const toolCallMap = new Map<string, ToolCall>()
|
||||
const turnMap = new Map<string, DirectTurn>()
|
||||
// Rebuild copilot gates still waiting on the user (request without a
|
||||
// matching response in the log) so reopening a blocked session shows them.
|
||||
const toolPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
const askHumans = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||
|
||||
for (const event of run.log as z.infer<typeof RunEvent>[]) {
|
||||
const ts = event.ts ? new Date(event.ts).getTime() : Date.now()
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
const text = messageText(msg.content)
|
||||
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as Array<{ type: string; toolCallId?: string; toolName?: string; arguments?: unknown }>) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: normalizeToolInput(part.arguments as ToolCall['input']),
|
||||
status: 'pending',
|
||||
timestamp: ts,
|
||||
}
|
||||
toolCallMap.set(toolCall.id, toolCall)
|
||||
loaded.push(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.trim()) {
|
||||
seenMessageIdsRef.current.add(event.messageId)
|
||||
loaded.push({ id: event.messageId, role: msg.role, content: text, timestamp: ts })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-invocation': {
|
||||
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existing) {
|
||||
existing.input = normalizeToolInput(event.input)
|
||||
existing.status = 'running'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-result': {
|
||||
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existing) {
|
||||
existing.result = event.result as ToolCall['result']
|
||||
existing.status = 'completed'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'code-run-event': {
|
||||
if (event.toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
if (!STRUCTURAL_EVENTS.has(event.event.type)) break
|
||||
let turn = turnMap.get(event.toolCallId)
|
||||
if (!turn) {
|
||||
turn = { kind: 'direct-turn', id: event.toolCallId, events: [], timestamp: ts }
|
||||
turnMap.set(event.toolCallId, turn)
|
||||
loaded.push(turn)
|
||||
}
|
||||
turn.events.push(event.event)
|
||||
} else {
|
||||
const existing = toolCallMap.get(event.toolCallId)
|
||||
if (existing) existing.codeRunEvents = [...(existing.codeRunEvents ?? []), event.event]
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-permission-request':
|
||||
toolPerms.set(event.toolCall.toolCallId, event)
|
||||
break
|
||||
case 'tool-permission-response':
|
||||
toolPerms.delete(event.toolCallId)
|
||||
break
|
||||
case 'ask-human-request':
|
||||
askHumans.set(event.toolCallId, event)
|
||||
break
|
||||
case 'ask-human-response':
|
||||
askHumans.delete(event.toolCallId)
|
||||
break
|
||||
case 'run-stopped':
|
||||
toolPerms.clear()
|
||||
askHumans.clear()
|
||||
break
|
||||
case 'error':
|
||||
loaded.push({ id: `error-${loaded.length}`, kind: 'error', message: event.error, timestamp: ts })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
setItems(loaded)
|
||||
setPendingToolPermissions(toolPerms)
|
||||
setPendingAskHumans(askHumans)
|
||||
}).catch(() => {
|
||||
// Run log unreadable — show an empty conversation rather than crashing.
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (compactionStatus !== 'running') return
|
||||
const timer = window.setTimeout(() => setCompactionStatus('stalled'), COMPACTION_STALLED_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [compactionStatus])
|
||||
|
||||
// Live event stream.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
// runs:events is schema-less on the wire (req: z.null()) — cast like App.tsx does.
|
||||
return window.ipc.on('runs:events', ((raw: unknown) => {
|
||||
const event = raw as z.infer<typeof RunEvent>
|
||||
if (event.runId !== sessionId) return
|
||||
switch (event.type) {
|
||||
case 'run-processing-start':
|
||||
setIsProcessing(true)
|
||||
break
|
||||
case 'run-processing-end':
|
||||
setIsProcessing(false)
|
||||
setCompactionStatus('idle')
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
// Anything still streaming that never landed as a message (e.g. the
|
||||
// turn errored) is flushed so the text isn't lost.
|
||||
setLiveText((text) => {
|
||||
if (text.trim()) {
|
||||
setItems((prev) => [...prev, {
|
||||
id: `assistant-flush-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
break
|
||||
case 'run-stopped':
|
||||
setIsProcessing(false)
|
||||
setCompactionStatus('idle')
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
setPendingToolPermissions(new Map())
|
||||
setPendingAskHumans(new Map())
|
||||
break
|
||||
case 'tool-permission-request':
|
||||
setPendingToolPermissions((prev) => new Map(prev).set(event.toolCall.toolCallId, event))
|
||||
break
|
||||
case 'tool-permission-response':
|
||||
setPendingToolPermissions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(event.toolCallId)
|
||||
return next
|
||||
})
|
||||
break
|
||||
case 'ask-human-request':
|
||||
setPendingAskHumans((prev) => new Map(prev).set(event.toolCallId, event))
|
||||
break
|
||||
case 'ask-human-response':
|
||||
setPendingAskHumans((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(event.toolCallId)
|
||||
return next
|
||||
})
|
||||
break
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') break
|
||||
if (seenMessageIdsRef.current.has(event.messageId)) break
|
||||
const text = messageText(msg.content)
|
||||
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as Array<{ type: string; toolCallId?: string; toolName?: string; arguments?: unknown }>) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: normalizeToolInput(part.arguments as ToolCall['input']),
|
||||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setItems((prev) => (prev.some((i) => isChatToolCall(i) && i.id === toolCall.id) ? prev : [...prev, toolCall]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text.trim()) break
|
||||
seenMessageIdsRef.current.add(event.messageId)
|
||||
const chatMessage: ChatMessage = {
|
||||
id: event.messageId,
|
||||
role: msg.role,
|
||||
content: text.replace(/<\/?voice>/g, ''),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
if (msg.role === 'assistant') setLiveText('')
|
||||
setItems((prev) => {
|
||||
// Replace the optimistic local echo of this user message if present.
|
||||
if (msg.role === 'user') {
|
||||
const at = prev.findIndex((item) =>
|
||||
'role' in item && item.role === 'user' && item.id.startsWith('local-') && item.content === text)
|
||||
if (at >= 0) {
|
||||
const next = [...prev]
|
||||
next[at] = chatMessage
|
||||
return next
|
||||
}
|
||||
}
|
||||
return [...prev, chatMessage]
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'llm-stream-event': {
|
||||
// Rowboat mode streaming text.
|
||||
const llmEvent = event.event as { type: string; delta?: string; toolCallId?: string; toolName?: string; input?: unknown }
|
||||
setIsProcessing(true)
|
||||
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
|
||||
setLiveText((prev) => prev + llmEvent.delta)
|
||||
} else if (llmEvent.type === 'tool-call' && llmEvent.toolCallId) {
|
||||
const toolCall: ToolCall = {
|
||||
id: llmEvent.toolCallId,
|
||||
name: llmEvent.toolName || 'tool',
|
||||
input: normalizeToolInput(llmEvent.input as ToolCall['input']),
|
||||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setItems((prev) => (prev.some((i) => isChatToolCall(i) && i.id === toolCall.id) ? prev : [...prev, toolCall]))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-invocation':
|
||||
setItems((prev) => prev.map((item) => (
|
||||
isChatToolCall(item) && item.id === event.toolCallId
|
||||
? { ...item, input: normalizeToolInput(event.input), status: 'running' as const }
|
||||
: item
|
||||
)))
|
||||
break
|
||||
case 'tool-result':
|
||||
setItems((prev) => prev.map((item) => (
|
||||
isChatToolCall(item) && item.id === event.toolCallId
|
||||
? { ...item, result: event.result as ToolCall['result'], status: 'completed' as const, pendingCodePermission: null }
|
||||
: item
|
||||
)))
|
||||
break
|
||||
case 'code-run-event': {
|
||||
setIsProcessing(true)
|
||||
if (event.event.type === 'usage') {
|
||||
setContextUsage({ used: event.event.used, size: event.event.size })
|
||||
}
|
||||
if (event.event.type === 'tool_call' && event.event.title === COMPACTION_TITLE) {
|
||||
compactionToolIdRef.current = event.event.id ?? null
|
||||
setCompactionStatus('running')
|
||||
}
|
||||
if (event.event.type === 'tool_call_update'
|
||||
&& event.event.id != null
|
||||
&& event.event.id === compactionToolIdRef.current) {
|
||||
compactionToolIdRef.current = null
|
||||
setCompactionStatus('idle')
|
||||
}
|
||||
if (event.event.type === 'message' && event.event.role === 'agent' && event.toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
const text = event.event.text
|
||||
setLiveText((prev) => prev + text)
|
||||
}
|
||||
if (event.event.type === 'permission') {
|
||||
setPendingPermission(null)
|
||||
}
|
||||
applyCodeRunEvent(event.toolCallId, event.event)
|
||||
break
|
||||
}
|
||||
case 'code-run-permission-request':
|
||||
setPendingPermission({ requestId: event.requestId, ask: event.ask, toolCallId: event.toolCallId })
|
||||
break
|
||||
case 'error':
|
||||
setItems((prev) => [...prev, {
|
||||
id: `error-${Date.now()}`,
|
||||
kind: 'error',
|
||||
message: event.error,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}) as unknown as (event: null) => void)
|
||||
}, [sessionId, applyCodeRunEvent])
|
||||
|
||||
const send = useCallback(async (text: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!session) return { ok: false, error: 'No session selected' }
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return { ok: false }
|
||||
// Optimistic echo, replaced by the persisted event when it arrives.
|
||||
setItems((prev) => [...prev, {
|
||||
id: `local-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
if (session.mode === 'direct') {
|
||||
const res = await window.ipc.invoke('codeSession:sendMessage', { sessionId: session.id, text: trimmed })
|
||||
if (!res.accepted) {
|
||||
setIsProcessing(false)
|
||||
return { ok: false, error: res.error ?? 'The session is busy.' }
|
||||
}
|
||||
} else {
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: session.id,
|
||||
message: trimmed,
|
||||
codeMode: session.agent,
|
||||
codeCwd: session.cwd,
|
||||
codePolicy: session.policy,
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
setIsProcessing(false)
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Failed to send message' }
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
await window.ipc.invoke('codeSession:stop', { sessionId })
|
||||
}, [sessionId])
|
||||
|
||||
const resolvePermission = useCallback(async (decision: PermissionDecision) => {
|
||||
if (!pendingPermission) return
|
||||
setPendingPermission(null)
|
||||
await window.ipc.invoke('codeRun:resolvePermission', {
|
||||
requestId: pendingPermission.requestId,
|
||||
decision,
|
||||
})
|
||||
}, [pendingPermission])
|
||||
|
||||
// Rowboat-mode copilot gates — same IPC the main chat uses.
|
||||
const respondToToolPermission = useCallback(async (
|
||||
toolCallId: string,
|
||||
subflow: string[],
|
||||
response: 'approve' | 'deny',
|
||||
scope?: 'once' | 'session' | 'always',
|
||||
) => {
|
||||
if (!sessionId) return
|
||||
setPendingToolPermissions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(toolCallId)
|
||||
return next
|
||||
})
|
||||
await window.ipc.invoke('runs:authorizePermission', {
|
||||
runId: sessionId,
|
||||
authorization: { subflow, toolCallId, response, scope },
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
const respondToAskHuman = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
|
||||
if (!sessionId) return
|
||||
setPendingAskHumans((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(toolCallId)
|
||||
return next
|
||||
})
|
||||
await window.ipc.invoke('runs:provideHumanInput', {
|
||||
runId: sessionId,
|
||||
reply: { subflow, toolCallId, response },
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
return {
|
||||
items,
|
||||
liveText,
|
||||
isProcessing,
|
||||
compactionStatus,
|
||||
contextUsage,
|
||||
pendingPermission,
|
||||
pendingToolPermissions,
|
||||
pendingAskHumans,
|
||||
loading,
|
||||
send,
|
||||
stop,
|
||||
resolvePermission,
|
||||
respondToToolPermission,
|
||||
respondToAskHuman,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { CodeProject, CodeSession, CodeSessionStatus, GitRepoInfo } from '@x/shared/src/code-sessions.js'
|
||||
|
||||
export interface ProjectRow {
|
||||
project: CodeProject
|
||||
git: GitRepoInfo
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<CodeSessionStatus, number> = {
|
||||
'needs-you': 0,
|
||||
working: 1,
|
||||
idle: 2,
|
||||
}
|
||||
|
||||
// Projects + sessions + live statuses for the Code section. Statuses stream
|
||||
// over `codeSession:status` (pushed by the main-process tracker); the lists
|
||||
// load on demand and on session lifecycle changes.
|
||||
export function useCodeSessions() {
|
||||
const [projects, setProjects] = useState<ProjectRow[]>([])
|
||||
const [sessions, setSessions] = useState<CodeSession[]>([])
|
||||
const [statuses, setStatuses] = useState<Record<string, CodeSessionStatus>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [projectsRes, sessionsRes] = await Promise.all([
|
||||
window.ipc.invoke('codeProject:list', null),
|
||||
window.ipc.invoke('codeSession:list', null),
|
||||
])
|
||||
setProjects(projectsRes.projects)
|
||||
setSessions(sessionsRes.sessions)
|
||||
setStatuses((prev) => ({ ...sessionsRes.statuses, ...prev }))
|
||||
} finally {
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
return window.ipc.on('codeSession:status', ({ sessionId, status }) => {
|
||||
setStatuses((prev) => (prev[sessionId] === status ? prev : { ...prev, [sessionId]: status }))
|
||||
// Turn boundaries bump lastActivityAt — refresh ordering when one ends.
|
||||
if (status === 'idle') {
|
||||
void window.ipc.invoke('codeSession:list', null).then((res) => setSessions(res.sessions))
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const statusOf = useCallback(
|
||||
(sessionId: string): CodeSessionStatus => statuses[sessionId] ?? 'idle',
|
||||
[statuses],
|
||||
)
|
||||
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
const rank = STATUS_RANK[statusOf(a.id)] - STATUS_RANK[statusOf(b.id)]
|
||||
if (rank !== 0) return rank
|
||||
return (b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt)
|
||||
})
|
||||
|
||||
return {
|
||||
projects,
|
||||
sessions: sortedSessions,
|
||||
statuses,
|
||||
statusOf,
|
||||
loaded,
|
||||
refresh,
|
||||
setSessions,
|
||||
}
|
||||
}
|
||||
254
apps/x/apps/renderer/src/components/code/workspace-pane.tsx
Normal file
254
apps/x/apps/renderer/src/components/code/workspace-pane.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
FileDiff,
|
||||
FilePlus2,
|
||||
FileX2,
|
||||
FileEdit,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus, GitStatusFile } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { CodeFileTree } from './file-tree'
|
||||
import { CodeFileViewer } from './file-viewer'
|
||||
import { DiffViewer } from './diff-viewer'
|
||||
|
||||
type GitStatus = {
|
||||
isRepo: boolean
|
||||
branch: string | null
|
||||
hasCommits: boolean
|
||||
files: GitStatusFile[]
|
||||
}
|
||||
|
||||
const STATE_ICON: Record<GitStatusFile['state'], typeof FileEdit> = {
|
||||
modified: FileEdit,
|
||||
added: FilePlus2,
|
||||
untracked: FilePlus2,
|
||||
deleted: FileX2,
|
||||
renamed: FileEdit,
|
||||
}
|
||||
|
||||
// Right pane of a coding session: a diff reviewer first (Changes), a code
|
||||
// browser second (Files). Read-only in v1 by design.
|
||||
export function WorkspacePane({
|
||||
session,
|
||||
status,
|
||||
openDiffPath,
|
||||
onDiffOpened,
|
||||
onSessionChanged,
|
||||
}: {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
// A file path requested from the chat (clicking a changed file in a tool call).
|
||||
openDiffPath: string | null
|
||||
onDiffOpened: () => void
|
||||
onSessionChanged: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<'changes' | 'files'>('changes')
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null)
|
||||
const [diffPath, setDiffPath] = useState<string | null>(null)
|
||||
const [filePath, setFilePath] = useState<string | null>(null)
|
||||
const [merging, setMerging] = useState(false)
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await window.ipc.invoke('codeSession:gitStatus', { sessionId: session.id })
|
||||
setGitStatus(res)
|
||||
} catch {
|
||||
setGitStatus(null)
|
||||
}
|
||||
}, [session.id])
|
||||
|
||||
useEffect(() => {
|
||||
setTab('changes')
|
||||
setDiffPath(null)
|
||||
setFilePath(null)
|
||||
void refreshStatus()
|
||||
}, [refreshStatus])
|
||||
|
||||
// Refresh on turn end, and poll lightly while the agent is working — the
|
||||
// session cwd lives outside the workspace watcher, so there are no change
|
||||
// events to react to.
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
void refreshStatus()
|
||||
return
|
||||
}
|
||||
const interval = setInterval(() => void refreshStatus(), 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [status, refreshStatus])
|
||||
|
||||
// Chat asked to show a specific file's diff.
|
||||
useEffect(() => {
|
||||
if (!openDiffPath) return
|
||||
// Tool events may carry absolute paths — make them cwd-relative.
|
||||
const rel = openDiffPath.startsWith(session.cwd + '/')
|
||||
? openDiffPath.slice(session.cwd.length + 1)
|
||||
: openDiffPath
|
||||
setTab('changes')
|
||||
setDiffPath(rel)
|
||||
onDiffOpened()
|
||||
}, [openDiffPath, session.cwd, onDiffOpened])
|
||||
|
||||
const handleMergeBack = async () => {
|
||||
setMerging(true)
|
||||
try {
|
||||
const res = await window.ipc.invoke('codeSession:mergeBack', { sessionId: session.id })
|
||||
if (res.ok) {
|
||||
toast.success(res.message)
|
||||
onSessionChanged()
|
||||
} else {
|
||||
toast.error(res.message, { duration: 10000 })
|
||||
}
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanup = async (deleteBranch: boolean) => {
|
||||
const res = await window.ipc.invoke('codeSession:cleanupWorktree', { sessionId: session.id, deleteBranch })
|
||||
if (res.success) {
|
||||
toast.success('Worktree removed. The session now works directly in the repo.')
|
||||
onSessionChanged()
|
||||
} else {
|
||||
toast.error(res.error ?? 'Failed to remove worktree')
|
||||
}
|
||||
}
|
||||
|
||||
const dirtyCount = gitStatus?.files.length ?? 0
|
||||
const worktreeActive = session.worktree && !session.worktree.removedAt
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header: branch + worktree controls */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{gitStatus?.isRepo ? (
|
||||
<>
|
||||
<GitBranch className="size-3.5 shrink-0" />
|
||||
<span className="truncate font-mono">{gitStatus.branch ?? '(no branch)'}</span>
|
||||
{dirtyCount > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
|
||||
{dirtyCount} changed
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>Not a git repository</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => void refreshStatus()} title="Refresh">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
{worktreeActive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2 text-xs">
|
||||
<GitMerge className="size-3.5" />
|
||||
Worktree
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={merging} onClick={() => void handleMergeBack()}>
|
||||
<GitMerge className="size-4" />
|
||||
Merge back into repo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void handleCleanup(false)}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove worktree (keep branch)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => void handleCleanup(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove worktree and branch
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b px-3 py-1.5">
|
||||
{(['changes', 'files'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors',
|
||||
tab === t ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{t === 'changes' ? `Changes${dirtyCount > 0 ? ` (${dirtyCount})` : ''}` : 'Files'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{tab === 'changes' && (
|
||||
diffPath ? (
|
||||
<DiffViewer sessionId={session.id} path={diffPath} onClose={() => setDiffPath(null)} />
|
||||
) : (
|
||||
<div className="h-full overflow-auto p-2">
|
||||
{!gitStatus?.isRepo && (
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
This folder isn't a git repository, so there's nothing to diff. The Files tab still works.
|
||||
</p>
|
||||
)}
|
||||
{gitStatus?.isRepo && gitStatus.files.length === 0 && (
|
||||
<p className="p-3 text-sm text-muted-foreground">No uncommitted changes.</p>
|
||||
)}
|
||||
{gitStatus?.files.map((file) => {
|
||||
const Icon = STATE_ICON[file.state]
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
type="button"
|
||||
onClick={() => setDiffPath(file.path)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-muted"
|
||||
title={file.path}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'size-3.5 shrink-0',
|
||||
file.state === 'deleted' ? 'text-red-500' : file.state === 'modified' || file.state === 'renamed' ? 'text-amber-500' : 'text-green-600',
|
||||
)} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono">{file.path}</span>
|
||||
{file.insertions !== null && <span className="shrink-0 text-green-600">+{file.insertions}</span>}
|
||||
{file.deletions !== null && <span className="shrink-0 text-red-500">−{file.deletions}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'files' && (
|
||||
filePath ? (
|
||||
<CodeFileViewer sessionId={session.id} path={filePath} onClose={() => setFilePath(null)} />
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<CodeFileTree sessionId={session.id} selectedPath={filePath} onSelectFile={setFilePath} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{tab === 'changes' && !diffPath && dirtyCount > 0 && (
|
||||
<div className="border-t px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
<FileDiff className="mr-1 inline size-3" />
|
||||
Click a file to review its diff.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleDot,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
Pencil,
|
||||
Search,
|
||||
ShieldQuestion,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Wrench,
|
||||
|
|
@ -16,7 +18,8 @@ import {
|
|||
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
import { getToolErrorText, toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
import { clearCodeRunBuffer, useCodeRunFeed } from '@/lib/code-run-feed'
|
||||
|
||||
// ── Timeline reduction ──────────────────────────────────────────────
|
||||
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
|
||||
|
|
@ -28,7 +31,7 @@ type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?:
|
|||
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
|
||||
type Row = TextRow | ToolRow | PlanRow | PermRow
|
||||
|
||||
function reduceEvents(events: CodeRunEvent[]): Row[] {
|
||||
export function reduceEvents(events: CodeRunEvent[]): Row[] {
|
||||
const rows: Row[] = []
|
||||
const toolIdx = new Map<string, number>()
|
||||
let planIdx = -1
|
||||
|
|
@ -87,7 +90,8 @@ function reduceEvents(events: CodeRunEvent[]): Row[] {
|
|||
return rows
|
||||
}
|
||||
|
||||
function toolKindIcon(kind?: string) {
|
||||
function toolKindIcon(kind?: string, title?: string) {
|
||||
if (title === 'Compacting context') return <Sparkles className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
switch (kind) {
|
||||
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
|
|
@ -107,9 +111,28 @@ function planMarker(status?: string) {
|
|||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||
|
||||
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
||||
export function CodingRunTimeline({
|
||||
events,
|
||||
error,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
events: CodeRunEvent[]
|
||||
error?: string
|
||||
// When set, changed-file names become clickable (the Code section opens the diff).
|
||||
onOpenDiff?: (path: string) => void
|
||||
}) {
|
||||
const rows = useMemo(() => reduceEvents(events), [events])
|
||||
if (rows.length === 0) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent…</div>
|
||||
}
|
||||
return (
|
||||
|
|
@ -117,28 +140,45 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
|||
{rows.map((row) => {
|
||||
if (row.kind === 'text') {
|
||||
return (
|
||||
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||
<p key={row.id} className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/90">
|
||||
{row.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'tool') {
|
||||
const running = row.status !== 'completed' && row.status !== 'failed'
|
||||
const failed = row.status === 'failed'
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{running
|
||||
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
|
||||
{toolKindIcon(row.toolKind)}
|
||||
{running ? (
|
||||
<Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : failed ? (
|
||||
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
|
||||
)}
|
||||
{toolKindIcon(row.toolKind, row.title)}
|
||||
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
|
||||
</div>
|
||||
{row.diffs.length > 0 && (
|
||||
<div className="ml-7 flex flex-col gap-0.5">
|
||||
{row.diffs.map((d) => (
|
||||
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
|
||||
{basename(d)}
|
||||
</span>
|
||||
onOpenDiff ? (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => onOpenDiff(d)}
|
||||
className="truncate text-left font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
title={d}
|
||||
>
|
||||
{basename(d)}
|
||||
</button>
|
||||
) : (
|
||||
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
|
||||
{basename(d)}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -168,6 +208,12 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -237,12 +283,23 @@ export function CodingRunBlock({
|
|||
(item.result as { agent?: string } | undefined)?.agent ??
|
||||
(item.input as { agent?: string } | undefined)?.agent
|
||||
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
|
||||
const error = getToolErrorText(item)
|
||||
// Timeline source: the durable record (item.codeRunEvents — the settle-time
|
||||
// batch, or the legacy path's inline accumulation) wins; while it's absent
|
||||
// the live CodeRunFeed buffer streams the run in real time.
|
||||
const liveEvents = useCodeRunFeed(item.id)
|
||||
const durableEvents = item.codeRunEvents
|
||||
const events = durableEvents?.length ? durableEvents : liveEvents
|
||||
// Once the durable batch has landed the buffer is redundant — drop it.
|
||||
useEffect(() => {
|
||||
if (durableEvents?.length) clearCodeRunBuffer(item.id)
|
||||
}, [durableEvents?.length, item.id])
|
||||
return (
|
||||
<>
|
||||
<Tool open={open} onOpenChange={onOpenChange}>
|
||||
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} />
|
||||
<CodingRunTimeline events={events} error={error} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
{item.pendingCodePermission && (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isErrorMessage,
|
||||
isToolCall,
|
||||
getToolDisplayName,
|
||||
getToolErrorText,
|
||||
toToolState,
|
||||
normalizeToolOutput,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
|
@ -62,7 +63,7 @@ function CompactToolRow({ tool }: { tool: ToolCall }) {
|
|||
const [open, setOpen] = useState(false)
|
||||
const title = getToolDisplayName(tool)
|
||||
const state = toToolState(tool.status)
|
||||
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
|
||||
const errorText = getToolErrorText(tool)
|
||||
return (
|
||||
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
|
||||
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { Suspense, lazy, useEffect, useRef, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
CloudDownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
UploadCloudIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { DocxEditorRef } from '@eigenpal/docx-editor-react'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
// The editor (and its CSS) is heavy and only needed when a .docx is open, so it
|
||||
// loads in its own chunk the first time a Word document is viewed.
|
||||
|
|
@ -16,6 +24,14 @@ interface DocxFileViewerProps {
|
|||
path: string
|
||||
}
|
||||
|
||||
type GoogleDocLink = {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
syncedAt: string
|
||||
remoteModifiedTime?: string
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
|
|
@ -51,6 +67,9 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [link, setLink] = useState<GoogleDocLink | null>(null)
|
||||
const [syncing, setSyncing] = useState<'up' | 'down' | null>(null)
|
||||
|
||||
const editorRef = useRef<DocxEditorRef>(null)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
|
@ -59,7 +78,7 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
const dirtyRef = useRef(false)
|
||||
const savingRef = useRef(false)
|
||||
|
||||
// Load the .docx bytes whenever the path changes.
|
||||
// Load the .docx bytes whenever the path changes or a sync-down reloads it.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoadState('loading')
|
||||
|
|
@ -87,10 +106,20 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
cancelled = true
|
||||
if (armTimerRef.current) clearTimeout(armTimerRef.current)
|
||||
}
|
||||
}, [path, reloadNonce])
|
||||
|
||||
// Is this file linked to a Google Doc? Drives the sync bar.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLink(null)
|
||||
void window.ipc.invoke('google-docs:getLink', { path })
|
||||
.then((res) => { if (!cancelled) setLink(res.link) })
|
||||
.catch((err) => { console.error('Failed to read Google Doc link:', err) })
|
||||
return () => { cancelled = true }
|
||||
}, [path])
|
||||
|
||||
// Serialize the current document and write it back to disk.
|
||||
const persist = async () => {
|
||||
const persist = useCallback(async () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || savingRef.current) return
|
||||
savingRef.current = true
|
||||
|
|
@ -115,7 +144,8 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
// A change landed while we were saving — flush it.
|
||||
if (dirtyRef.current) scheduleSave()
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path])
|
||||
|
||||
const scheduleSave = () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
|
|
@ -137,6 +167,66 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path])
|
||||
|
||||
// Write any pending edits to disk before a sync-up so we push the latest.
|
||||
const flushPendingSave = useCallback(async () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
if (dirtyRef.current || savingRef.current) {
|
||||
await persist()
|
||||
}
|
||||
}, [persist])
|
||||
|
||||
const handleSyncDown = useCallback(async () => {
|
||||
if (syncing) return
|
||||
setSyncing('down')
|
||||
try {
|
||||
await window.ipc.invoke('google-docs:refreshSnapshot', { path })
|
||||
// Reload the freshly-written bytes into the editor.
|
||||
armedRef.current = false
|
||||
dirtyRef.current = false
|
||||
setReloadNonce((n) => n + 1)
|
||||
const res = await window.ipc.invoke('google-docs:getLink', { path })
|
||||
setLink(res.link)
|
||||
toast.success('Pulled latest from Google Docs')
|
||||
} catch (err) {
|
||||
console.error('Sync down failed:', err)
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to pull from Google Docs')
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
}, [path, syncing])
|
||||
|
||||
const handleSyncUp = useCallback(async () => {
|
||||
if (syncing) return
|
||||
setSyncing('up')
|
||||
try {
|
||||
await flushPendingSave()
|
||||
let result = await window.ipc.invoke('google-docs:sync', { path })
|
||||
if (result.conflict) {
|
||||
const overwrite = window.confirm(
|
||||
'This Google Doc changed since your last sync.\n\n' +
|
||||
'Overwrite it with your local version? Cancel to keep the remote copy ' +
|
||||
'(use “Sync down” to pull it first).',
|
||||
)
|
||||
if (!overwrite) {
|
||||
toast.info('Sync up cancelled — remote Google Doc is unchanged')
|
||||
return
|
||||
}
|
||||
result = await window.ipc.invoke('google-docs:sync', { path, force: true })
|
||||
}
|
||||
if (!result.synced) {
|
||||
throw new Error(result.error || 'This file is not linked to a Google Doc.')
|
||||
}
|
||||
const res = await window.ipc.invoke('google-docs:getLink', { path })
|
||||
setLink(res.link)
|
||||
toast.success('Pushed changes to Google Docs')
|
||||
} catch (err) {
|
||||
console.error('Sync up failed:', err)
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to push to Google Docs')
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
}, [path, syncing, flushPendingSave])
|
||||
|
||||
if (loadState === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
|
|
@ -155,37 +245,75 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (loadState === 'loading' || !buffer) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading document…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading editor…</p>
|
||||
{link && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/30 px-3 py-1.5 text-xs">
|
||||
<GoogleDocsIcon className="size-4 shrink-0" />
|
||||
<span className="truncate font-medium text-foreground">{link.title}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{syncing
|
||||
? syncing === 'up' ? 'Syncing up…' : 'Syncing down…'
|
||||
: `Synced ${formatRelativeTime(link.syncedAt)}`}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleSyncDown() }}
|
||||
disabled={Boolean(syncing)}
|
||||
title="Pull latest from Google Docs"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<CloudDownloadIcon className="size-3.5" /> Sync down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleSyncUp() }}
|
||||
disabled={Boolean(syncing)}
|
||||
title="Push your changes to Google Docs"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<UploadCloudIcon className="size-3.5" /> Sync up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.open(link.url, '_blank') }}
|
||||
title="Open in Google Docs"
|
||||
className="inline-flex items-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyDocxEditor
|
||||
key={path}
|
||||
ref={editorRef}
|
||||
documentBuffer={buffer}
|
||||
mode="editing"
|
||||
documentName={baseName(path)}
|
||||
documentNameEditable={false}
|
||||
onChange={handleChange}
|
||||
onError={(err) => { console.error('docx editor error:', err) }}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'loading' || !buffer ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading document…</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading editor…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyDocxEditor
|
||||
key={`${path}:${reloadNonce}`}
|
||||
ref={editorRef}
|
||||
documentBuffer={buffer}
|
||||
mode="editing"
|
||||
documentName={baseName(path)}
|
||||
documentNameEditable={false}
|
||||
onChange={handleChange}
|
||||
onError={(err) => { console.error('docx editor error:', err) }}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{saveState !== 'idle' && (
|
||||
<div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
|
||||
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'}
|
||||
|
|
@ -194,3 +322,13 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GoogleDocsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
|
||||
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
|
||||
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
|
||||
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,17 +26,24 @@ import {
|
|||
Trash2Icon,
|
||||
ImageIcon,
|
||||
DownloadIcon,
|
||||
ChevronDownIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
CloudDownloadIcon,
|
||||
LoaderIcon,
|
||||
UploadCloudIcon,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
editor: Editor | null
|
||||
|
|
@ -45,6 +52,16 @@ interface EditorToolbarProps {
|
|||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
onOpenLiveNote?: () => void
|
||||
liveState?: LivePillState
|
||||
googleDoc?: GoogleDocToolbarState
|
||||
}
|
||||
|
||||
export interface GoogleDocToolbarState {
|
||||
title: string
|
||||
isSyncing?: 'up' | 'down' | null
|
||||
lastSyncedAt?: string
|
||||
onOpen: () => void
|
||||
onSyncDown: () => void
|
||||
onSyncUp: () => void
|
||||
}
|
||||
|
||||
export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error'
|
||||
|
|
@ -60,6 +77,36 @@ const LIVE_PILL_VARIANT_CLASS: Record<LivePillVariant, string> = {
|
|||
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
|
||||
}
|
||||
|
||||
function GoogleDocsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
|
||||
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
|
||||
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
editor,
|
||||
onSelectionHighlight,
|
||||
|
|
@ -67,6 +114,7 @@ export function EditorToolbar({
|
|||
onExport,
|
||||
onOpenLiveNote,
|
||||
liveState,
|
||||
googleDoc,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -404,6 +452,51 @@ export function EditorToolbar({
|
|||
</>
|
||||
)}
|
||||
|
||||
{googleDoc && (
|
||||
<>
|
||||
<div className="separator" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2"
|
||||
title={`Google Doc: ${googleDoc.title}`}
|
||||
disabled={Boolean(googleDoc.isSyncing)}
|
||||
>
|
||||
{googleDoc.isSyncing ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<GoogleDocsIcon className="size-4" />
|
||||
)}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="font-normal text-muted-foreground">
|
||||
{googleDoc.lastSyncedAt
|
||||
? `Last synced ${formatRelativeTime(googleDoc.lastSyncedAt)}`
|
||||
: 'Not synced yet'}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={googleDoc.onOpen}>
|
||||
<GoogleDriveIcon className="size-4 mr-2" />
|
||||
Open Google Doc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={googleDoc.onSyncDown} disabled={Boolean(googleDoc.isSyncing)}>
|
||||
<CloudDownloadIcon className="size-4 mr-2" />
|
||||
Sync down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={googleDoc.onSyncUp} disabled={Boolean(googleDoc.isSyncing)}>
|
||||
<UploadCloudIcon className="size-4 mr-2" />
|
||||
Sync up
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live Note pill — pushed to far right */}
|
||||
{onOpenLiveNote && liveState && (
|
||||
<button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
142
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal file
142
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FileText, Loader2, RefreshCw } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
type GoogleDocPickerDialogProps = {
|
||||
open: boolean
|
||||
targetFolder: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onImported: (path: string) => void
|
||||
}
|
||||
|
||||
export function GoogleDocPickerDialog({
|
||||
open,
|
||||
targetFolder,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: GoogleDocPickerDialogProps) {
|
||||
// The managed picker runs its own drive.file OAuth in the browser, gated on
|
||||
// the Rowboat web session. So the only desktop prerequisite is being signed
|
||||
// in to Rowboat — it needs NO prior Google connection and NO drive.file scope
|
||||
// on the main grant (the picker grants drive.file per-file as you choose).
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const account = await window.ipc.invoke('account:getRowboat', null)
|
||||
setSignedIn(account.signedIn)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setSignedIn(null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to check your Rowboat sign-in')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
void loadStatus()
|
||||
}, [loadStatus, open])
|
||||
|
||||
const handleChoose = useCallback(async () => {
|
||||
setError(null)
|
||||
setOpening(true)
|
||||
|
||||
// Managed pick: the Rowboat backend runs the whole grant + pick in the
|
||||
// browser with the company Google client, then deep-links the selection
|
||||
// back. No API key, BYOK creds, or redirect URL to configure. Close our
|
||||
// modal during the hand-off.
|
||||
onOpenChange(false)
|
||||
toast('Continue in your browser: grant access and pick a document…', 'info')
|
||||
let result: { path: string; doc: { name: string } } | null = null
|
||||
try {
|
||||
result = await window.ipc.invoke('google-docs:pickViaManaged', { targetFolder })
|
||||
} catch (err) {
|
||||
setOpening(false)
|
||||
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
setOpening(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast(`Added “${result.doc.name}”`, 'success')
|
||||
onImported(result.path)
|
||||
setOpening(false)
|
||||
}, [targetFolder, onImported, onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||
<DialogTitle>Add Google Doc</DialogTitle>
|
||||
<DialogDescription>
|
||||
Link a Google Doc or Word file from Drive into {targetLabel}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{signedIn === null && error ? (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<div className="max-w-sm text-sm text-destructive">{error}</div>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
<RefreshCw className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : signedIn === null ? (
|
||||
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Checking your Rowboat sign-in…
|
||||
</div>
|
||||
) : !signedIn ? (
|
||||
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||
<div className="max-w-sm text-sm text-muted-foreground">
|
||||
Sign in to Rowboat to add Google Docs from Drive. The picker uses your
|
||||
Rowboat account — no Google credentials or API key needed.
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
<RefreshCw className="size-4" />
|
||||
I've signed in — retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||
<div className="max-w-sm text-sm text-muted-foreground">
|
||||
Pick a Google Doc or Word file from your Drive. It imports as an editable
|
||||
<code> .docx</code> and stays linked for two-way sync.
|
||||
</div>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
You'll continue in your browser to grant access and choose a document — no
|
||||
API key or setup needed.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="w-full max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => void handleChoose()} disabled={opening}>
|
||||
{opening ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||
Choose from Google Drive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
|
||||
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
|
|
@ -54,7 +54,17 @@ type RawCalEvent = {
|
|||
}
|
||||
|
||||
type EmailThread = { threadId: string; subject: string; from: string }
|
||||
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
|
||||
type SlackFeedMessage = {
|
||||
id: string
|
||||
workspaceName?: string
|
||||
workspaceUrl?: string
|
||||
channelId?: string
|
||||
channelName?: string
|
||||
author?: string
|
||||
text: string
|
||||
ts: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
function greeting(): string {
|
||||
const h = new Date().getHours()
|
||||
|
|
@ -94,6 +104,28 @@ function relativeAgo(iso?: string): string {
|
|||
return `${d}d ago`
|
||||
}
|
||||
|
||||
function relativeSlackTs(ts: string): string {
|
||||
const seconds = Number(ts.split('.')[0])
|
||||
if (!Number.isFinite(seconds)) return ''
|
||||
const iso = new Date(seconds * 1000).toISOString()
|
||||
return relativeAgo(iso)
|
||||
}
|
||||
|
||||
// Short, non-actionable copy for the home feed — the actionable fix lives in
|
||||
// Settings, so every failure routes the user there.
|
||||
function homeSlackErrorCopy(kind: string | null): string {
|
||||
switch (kind) {
|
||||
case 'not_authed':
|
||||
return 'Slack needs reconnecting — open Settings → Connected accounts.'
|
||||
case 'network':
|
||||
return "Couldn't reach Slack. Check your connection."
|
||||
case 'rate_limited':
|
||||
return 'Slack is rate-limiting requests — will retry shortly.'
|
||||
default:
|
||||
return "Couldn't load Slack right now — see Settings."
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllDay(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (!m) return null
|
||||
|
|
@ -154,54 +186,6 @@ function triggerMeetingCapture(event: CalEvent, openConference: boolean) {
|
|||
}
|
||||
|
||||
const CARD = 'rounded-xl border border-border bg-card p-4'
|
||||
const TOOLKIT_PREVIEW_LIMIT = 8
|
||||
|
||||
let cachedToolkitPreviews: ToolkitPreview[] | null = null
|
||||
let cachedToolkitLogosLoaded = false
|
||||
|
||||
function ToolkitPreviewIcon({
|
||||
toolkit,
|
||||
onInvalid,
|
||||
}: {
|
||||
toolkit: ToolkitPreview
|
||||
onInvalid: (slug: string) => void
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="hidden"
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget
|
||||
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
|
||||
setLoaded(true)
|
||||
} else {
|
||||
onInvalid(toolkit.slug)
|
||||
}
|
||||
}}
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={`${toolkit.name}: ${toolkit.description}`}
|
||||
aria-label={toolkit.name}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
|
||||
>
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="size-5 shrink-0 object-contain"
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeView({
|
||||
tree,
|
||||
|
|
@ -218,9 +202,10 @@ export function HomeView({
|
|||
}: HomeViewProps) {
|
||||
const [events, setEvents] = useState<CalEvent[]>([])
|
||||
const [emails, setEmails] = useState<EmailThread[]>([])
|
||||
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
|
||||
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
|
||||
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
|
||||
const [slackError, setSlackError] = useState<string | null>(null)
|
||||
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -260,40 +245,23 @@ export function HomeView({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadConnectorLogos = useCallback(async () => {
|
||||
if (cachedToolkitLogosLoaded) return
|
||||
const loadSlackMessages = useCallback(async () => {
|
||||
try {
|
||||
const configured = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configured.configured) return
|
||||
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
|
||||
const previews = toolkits.items
|
||||
.filter((toolkit) => Boolean(toolkit.meta.logo))
|
||||
.slice(0, TOOLKIT_PREVIEW_LIMIT)
|
||||
.map((toolkit) => ({
|
||||
slug: toolkit.slug,
|
||||
logo: toolkit.meta.logo,
|
||||
name: toolkit.name,
|
||||
description: toolkit.meta.description,
|
||||
}))
|
||||
cachedToolkitPreviews = previews
|
||||
setToolkitPreviews(previews)
|
||||
} catch {
|
||||
cachedToolkitPreviews = []
|
||||
} finally {
|
||||
cachedToolkitLogosLoaded = true
|
||||
setToolkitLogosLoaded(true)
|
||||
const result = await window.ipc.invoke('slack:getRecentMessages', { limit: 5 })
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackMessages(result.messages)
|
||||
setSlackError(result.error ?? null)
|
||||
setSlackErrorKind(result.errorKind ?? null)
|
||||
} catch (err) {
|
||||
console.error('Home: failed to load Slack messages', err)
|
||||
setSlackEnabled(false)
|
||||
setSlackMessages([])
|
||||
setSlackError(null)
|
||||
setSlackErrorKind(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToolkitPreview = useCallback((slug: string) => {
|
||||
setToolkitPreviews((prev) => {
|
||||
const next = prev.filter((toolkit) => toolkit.slug !== slug)
|
||||
cachedToolkitPreviews = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos])
|
||||
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages() }, [loadEvents, loadEmails, loadSlackMessages])
|
||||
|
||||
// Upcoming (not-yet-ended) events, soonest first.
|
||||
const upcoming = useMemo(() => {
|
||||
|
|
@ -460,6 +428,53 @@ export function HomeView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
{slackEnabled && (
|
||||
<div className={CARD}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="size-[15px]" />
|
||||
<span className="text-sm font-medium">Slack</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">Latest messages</span>
|
||||
</div>
|
||||
{slackError ? (
|
||||
<div className="py-1 text-[12.5px] text-muted-foreground">{homeSlackErrorCopy(slackErrorKind)}</div>
|
||||
) : slackMessages.length === 0 ? (
|
||||
<div className="py-1 text-[12.5px] text-muted-foreground">No messages worth surfacing right now.</div>
|
||||
) : slackMessages.map((message, i) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex items-start gap-3 py-2 text-[12.5px] ${i ? 'border-t border-border' : ''}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] text-muted-foreground">
|
||||
<span className="truncate">{message.channelName ?? 'Slack'}</span>
|
||||
{message.author && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="truncate">{message.author}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">{relativeSlackTs(message.ts)}</span>
|
||||
</div>
|
||||
<div className="line-clamp-2 text-foreground">{message.text}</div>
|
||||
</div>
|
||||
{message.url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(message.url, '_blank')}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-primary transition-colors hover:bg-accent"
|
||||
>
|
||||
Open
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today's schedule */}
|
||||
<div className={CARD}>
|
||||
<div className="mb-3.5 flex items-center gap-2">
|
||||
|
|
@ -524,41 +539,7 @@ export function HomeView({
|
|||
)}
|
||||
|
||||
{/* Tool connections */}
|
||||
<div className={CARD}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
|
||||
<Plug className="size-[14px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] leading-snug">
|
||||
<span className="font-medium">Connect your tools.</span>
|
||||
<span className="text-muted-foreground"> Bring context from the apps you already use.</span>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
|
||||
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
|
||||
<ToolkitPreviewIcon
|
||||
key={toolkit.slug}
|
||||
toolkit={toolkit}
|
||||
onInvalid={removeToolkitPreview}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionsSettingsOpen(true)}
|
||||
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
|
||||
>
|
||||
Connections
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsDialog
|
||||
defaultTab="connections"
|
||||
open={connectionsSettingsOpen}
|
||||
onOpenChange={setConnectionsSettingsOpen}
|
||||
/>
|
||||
<ToolConnectionsCard />
|
||||
|
||||
{/* Open chat CTA */}
|
||||
{onOpenChat && (
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
<iframe
|
||||
key={path}
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
// `allow-popups` lets `target="_blank"` links reach the main process
|
||||
// window-open handler, which routes them to the system browser. Plain
|
||||
// links (same-frame navigations) are handled there via
|
||||
// `will-frame-navigate`. No `allow-same-origin` — the doc stays
|
||||
// origin-isolated.
|
||||
sandbox="allow-scripts allow-popups"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
|
|
@ -38,6 +38,7 @@ interface TreeNode {
|
|||
|
||||
export type KnowledgeViewActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
addGoogleDoc: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
|
|
@ -46,17 +47,21 @@ export type KnowledgeViewActions = {
|
|||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
export type KnowledgeViewMode = 'graph' | 'basis' | 'files'
|
||||
|
||||
type KnowledgeViewProps = {
|
||||
tree: TreeNode[]
|
||||
actions: KnowledgeViewActions
|
||||
mode: KnowledgeViewMode
|
||||
onModeChange: (mode: KnowledgeViewMode) => void
|
||||
graphContent: ReactNode
|
||||
basisContent: ReactNode
|
||||
// Folder currently being browsed (null = root overview). Controlled by the
|
||||
// app so drill-down participates in the global back/forward history.
|
||||
folderPath: string | null
|
||||
onNavigateFolder: (path: string | null) => void
|
||||
onOpenNote: (path: string) => void
|
||||
onOpenGraph: () => void
|
||||
onOpenSearch: () => void
|
||||
onOpenBases: () => void
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +109,21 @@ function latestMtime(node: TreeNode): number {
|
|||
return max
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
|
|
@ -145,12 +165,14 @@ function displayName(node: TreeNode): string {
|
|||
export function KnowledgeView({
|
||||
tree,
|
||||
actions,
|
||||
mode,
|
||||
onModeChange,
|
||||
graphContent,
|
||||
basisContent,
|
||||
folderPath,
|
||||
onNavigateFolder,
|
||||
onOpenNote,
|
||||
onOpenGraph,
|
||||
onOpenSearch,
|
||||
onOpenBases,
|
||||
onVoiceNoteCreated,
|
||||
}: KnowledgeViewProps) {
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
|
|
@ -184,27 +206,46 @@ export function KnowledgeView({
|
|||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Brain</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
|
||||
{folders.length === 1 ? 'folder' : 'folders'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="inline-flex overflow-hidden rounded-lg border border-border bg-background">
|
||||
<ViewModeButton
|
||||
icon={Network}
|
||||
label="Graph"
|
||||
active={mode === 'graph'}
|
||||
onClick={() => onModeChange('graph')}
|
||||
/>
|
||||
<ViewModeButton
|
||||
icon={Table2}
|
||||
label="Base"
|
||||
active={mode === 'basis'}
|
||||
onClick={() => onModeChange('basis')}
|
||||
/>
|
||||
<ViewModeButton
|
||||
icon={FileText}
|
||||
label="Files"
|
||||
active={mode === 'files'}
|
||||
onClick={() => onModeChange('files')}
|
||||
/>
|
||||
</div>
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => actions.createNote(currentFolder?.path)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<FilePlus className="size-4" />
|
||||
<span>New note</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'graph' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{graphContent}
|
||||
</div>
|
||||
) : mode === 'basis' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{basisContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
{currentFolder ? (
|
||||
|
|
@ -267,11 +308,12 @@ export function KnowledgeView({
|
|||
<QuickActions
|
||||
actions={actions}
|
||||
currentFolder={currentFolder}
|
||||
onOpenBases={onOpenBases}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onFolderCreated={setRenameTarget}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -279,12 +321,12 @@ export function KnowledgeView({
|
|||
function QuickActions({
|
||||
actions,
|
||||
currentFolder,
|
||||
onOpenBases,
|
||||
onOpenSearch,
|
||||
onFolderCreated,
|
||||
}: {
|
||||
actions: KnowledgeViewActions
|
||||
currentFolder: TreeNode | null
|
||||
onOpenBases: () => void
|
||||
onOpenSearch: () => void
|
||||
onFolderCreated: (path: string) => void
|
||||
}) {
|
||||
// Inside a folder these target that folder; at the root they target knowledge/.
|
||||
|
|
@ -294,6 +336,8 @@ function QuickActions({
|
|||
<SectionHeader label="Quick actions" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
|
||||
<QuickAction icon={GoogleDriveIcon} label="Add Google Doc" onClick={() => actions.addGoogleDoc(parent)} />
|
||||
<QuickAction icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<QuickAction
|
||||
icon={FolderPlus}
|
||||
label="New folder"
|
||||
|
|
@ -304,7 +348,6 @@ function QuickActions({
|
|||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
|
||||
<QuickAction
|
||||
icon={FolderOpen}
|
||||
label={`Reveal in ${getFileManagerName()}`}
|
||||
|
|
@ -315,20 +358,26 @@ function QuickActions({
|
|||
)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
function ViewModeButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof SearchIcon
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{label}</span>
|
||||
|
|
@ -341,7 +390,7 @@ function QuickAction({
|
|||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof FilePlus
|
||||
icon: typeof FilePlus | typeof GoogleDriveIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
|
|
@ -532,7 +581,7 @@ function FolderDetail({
|
|||
onClick={() => onNavigate(null)}
|
||||
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Notes
|
||||
Brain
|
||||
</button>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={c.path} className="flex min-w-0 items-center gap-1.5">
|
||||
|
|
@ -764,6 +813,10 @@ function RowContextMenu({
|
|||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.addGoogleDoc(node.path)}>
|
||||
<GoogleDriveIcon className="mr-2 size-4" />
|
||||
Add Google Doc
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ import {
|
|||
ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import type z from 'zod'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
|
||||
export type OpenLiveNotePanelDetail = {
|
||||
|
|
@ -661,7 +659,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
function LastRunTab({ live }: { live: LiveNote }) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loadingRun, setLoadingRun] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -669,7 +667,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
setFetchError(null)
|
||||
setLoadingRun(false)
|
||||
return
|
||||
|
|
@ -679,13 +677,13 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
setFetchError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setFetchError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoadingRun(false)
|
||||
}
|
||||
|
|
@ -704,7 +702,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
}
|
||||
|
||||
const isError = !!live.lastRunError
|
||||
const items = run ? runLogToConversation(run.log) : []
|
||||
const items = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
|
||||
|
|
@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
Couldn't load transcript: {fetchError}
|
||||
</div>
|
||||
)}
|
||||
{run && !loadingRun && items.length === 0 && (
|
||||
{transcript && !loadingRun && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loadingRun && items.length > 0 && (
|
||||
{transcript && !loadingRun && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ function computeWithinBlockOffset(
|
|||
return 0
|
||||
}
|
||||
}
|
||||
import { EditorToolbar, type LivePillState } from './editor-toolbar'
|
||||
import { EditorToolbar, type GoogleDocToolbarState, type LivePillState } from './editor-toolbar'
|
||||
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
|
|
@ -448,6 +448,7 @@ interface MarkdownEditorProps {
|
|||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
notePath?: string
|
||||
googleDoc?: GoogleDocToolbarState
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -645,6 +646,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
googleDoc,
|
||||
}, ref) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -1628,6 +1630,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
googleDoc={googleDoc}
|
||||
onOpenLiveNote={notePath ? () => {
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
|
||||
detail: { filePath: notePath },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings'
|
|||
const CALENDAR_DIR = 'calendar_sync'
|
||||
const UPCOMING_MAX_DAYS = 4 // today + next 3
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingMeetingPrepCreate?: { prompt: string }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the `meeting-prep:resolve` IPC response shape.
|
||||
type PrepNote = {
|
||||
path: string
|
||||
name: string
|
||||
role?: string
|
||||
organization?: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepAttendee = {
|
||||
label: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
note: PrepNote | null
|
||||
}
|
||||
type PrepOrg = {
|
||||
path: string
|
||||
name: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepResult = {
|
||||
attendees: PrepAttendee[]
|
||||
organizations: PrepOrg[]
|
||||
prepNote: { path: string; brief: string } | null
|
||||
matchedCount: number
|
||||
unmatchedCount: number
|
||||
}
|
||||
|
||||
type MeetingNoteRow = {
|
||||
path: string
|
||||
name: string
|
||||
|
|
@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] {
|
|||
}).filter((part) => part.text.length > 0)
|
||||
}
|
||||
|
||||
function UpcomingEvents() {
|
||||
// Hand the unmatched attendee off to the Copilot to research + create a note.
|
||||
function requestCreateNote(attendee: PrepAttendee, meetingSummary: string) {
|
||||
const who = attendee.displayName || attendee.label
|
||||
const email = attendee.email ? ` <${attendee.email}>` : ''
|
||||
window.__pendingMeetingPrepCreate = {
|
||||
prompt: `Create a person note in my knowledge base for ${who}${email}. They're attending my "${meetingSummary}" meeting. Pull together what you know about them from my emails, past meetings, and calendar.`,
|
||||
}
|
||||
window.dispatchEvent(new Event('meeting-prep:create-note'))
|
||||
}
|
||||
|
||||
// One note row (used for both people and organizations): a clickable row that
|
||||
// navigates to the note. The markdown is NOT rendered inline in the card.
|
||||
function PrepNoteRow({ title, subtitle, path, onOpenNote }: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
path: string
|
||||
onOpenNote: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(path)}
|
||||
title={`Open ${title}`}
|
||||
className="flex w-full items-center gap-3 border-b px-5 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[12.5px] font-semibold text-foreground">{title}</span>
|
||||
{subtitle ? <span className="truncate text-[11px] text-muted-foreground">{subtitle}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PrepAttendeeNote({ attendee, onOpenNote }: { attendee: PrepAttendee; onOpenNote: (path: string) => void }) {
|
||||
const note = attendee.note
|
||||
if (!note) return null
|
||||
const subtitle = [note.role, note.organization].filter(Boolean).join(' · ')
|
||||
return <PrepNoteRow title={note.name} subtitle={subtitle || undefined} path={note.path} onOpenNote={onOpenNote} />
|
||||
}
|
||||
|
||||
function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (attendees.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{open ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{attendees.length} {attendees.length === 1 ? 'other' : 'others'} — no notes yet
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="flex flex-col gap-1 px-5 pb-3 pl-12">
|
||||
{attendees.map((att, idx) => (
|
||||
<div key={`${att.email ?? att.label}-${idx}`} className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="min-w-0 truncate text-sm text-foreground">{att.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestCreateNote(att, meetingSummary)}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<UserPlus className="size-3.5" />
|
||||
Create note
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline prep for a single event: resolves the attendees against the knowledge
|
||||
// base and renders their notes directly beneath the event row. Re-resolves when
|
||||
// a person note changes (e.g. after "Create note") so it stays fresh.
|
||||
function InlineMeetingPrep({ event, onOpenNote }: { event: UpcomingEvent; onOpenNote: (path: string) => void }) {
|
||||
const [prep, setPrep] = useState<PrepResult | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
const attendees = event.attendees.map((a) => ({ email: a.email, displayName: a.displayName, self: a.self }))
|
||||
const result = await window.ipc.invoke('meeting-prep:resolve', { attendees, eventId: event.id })
|
||||
if (!cancelled) setPrep(result)
|
||||
} catch (err) {
|
||||
console.error('Meeting prep failed:', err)
|
||||
if (!cancelled) setPrep(null)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [event.id, refreshTick])
|
||||
|
||||
// Refresh when a People note is created/changed so newly-created notes appear.
|
||||
useEffect(() => {
|
||||
const isPeoplePath = (p: string | undefined) =>
|
||||
typeof p === 'string' && p.startsWith('knowledge/People/')
|
||||
const cleanup = window.ipc.on('workspace:didChange', (e) => {
|
||||
switch (e.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isPeoplePath(e.path)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'moved':
|
||||
if (isPeoplePath(e.from) || isPeoplePath(e.to)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!e.paths || e.paths.some(isPeoplePath)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
if (!prep || prep.attendees.length === 0) return null
|
||||
|
||||
const matched = prep.attendees.filter((a) => a.note)
|
||||
const unmatched = prep.attendees.filter((a) => !a.note)
|
||||
|
||||
return (
|
||||
<div className="bg-muted/10">
|
||||
{prep.prepNote && prep.prepNote.brief ? (
|
||||
<div className="border-b px-5 pb-3 pt-3">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 [&_p]:text-[12.5px] [&_li]:text-[12.5px]">
|
||||
{prep.prepNote.brief}
|
||||
</Streamdown>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(prep.prepNote!.path)}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
Open full prep
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1.5 px-5 pb-1 pt-2.5">
|
||||
<UsersRound className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">People</span>
|
||||
</div>
|
||||
{matched.map((att, idx) => (
|
||||
<PrepAttendeeNote key={att.note!.path + idx} attendee={att} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
<PrepUnmatchedSection attendees={unmatched} meetingSummary={event.summary} />
|
||||
{prep.organizations.length > 0 ? (
|
||||
<>
|
||||
<div className="px-5 pb-1 pt-2.5">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prep.organizations.length === 1 ? 'Company' : 'Companies'}
|
||||
</span>
|
||||
</div>
|
||||
{prep.organizations.map((org) => (
|
||||
<PrepNoteRow key={org.path} title={org.name} subtitle="Organization" path={org.path} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) {
|
||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -487,6 +692,20 @@ function UpcomingEvents() {
|
|||
return selectVisibleDays(window)
|
||||
}, [events])
|
||||
|
||||
// The next meeting that's worth prepping for — soonest timed event with at
|
||||
// least one other attendee that hasn't ended. Its row gets inline prep.
|
||||
// `events` is sorted (all-day first, then by start), so `find` returns it.
|
||||
const prepEventId = useMemo(() => {
|
||||
const nowMs = Date.now()
|
||||
const candidate = events.find((ev) => {
|
||||
if (ev.isAllDay) return false
|
||||
if (ev.attendees.every((a) => a.self)) return false
|
||||
const endMs = ev.end ? ev.end.getTime() : ev.start.getTime() + 30 * 60 * 1000
|
||||
return endMs > nowMs
|
||||
})
|
||||
return candidate?.id ?? null
|
||||
}, [events])
|
||||
|
||||
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
|
||||
const now = new Date()
|
||||
const todayKey = localDateKey(now)
|
||||
|
|
@ -532,6 +751,8 @@ function UpcomingEvents() {
|
|||
key={day.dateKey}
|
||||
day={day}
|
||||
isToday={day.dateKey === todayKey}
|
||||
prepEventId={prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -542,7 +763,7 @@ function UpcomingEvents() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
|
||||
function UpcomingDayCard({ day, isToday, prepEventId, onOpenNote }: { day: DayGroup; isToday: boolean; prepEventId: string | null; onOpenNote: (path: string) => void }) {
|
||||
const dayNum = day.date.getDate()
|
||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||
|
|
@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean })
|
|||
</div>
|
||||
) : (
|
||||
day.events.map((ev, idx) => (
|
||||
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
|
||||
<UpcomingEventItem
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
isLast={idx === count - 1}
|
||||
isPrepTarget={ev.id === prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -588,14 +815,20 @@ function NowBadge() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
|
||||
function UpcomingEventItem({ event, isLast, isPrepTarget, onOpenNote }: { event: UpcomingEvent; isLast: boolean; isPrepTarget: boolean; onOpenNote: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// The next meeting auto-expands its prep; any other meeting with attendees
|
||||
// can be expanded on demand via the Prep toggle (resolves lazily on open).
|
||||
const prepEligible = !event.isAllDay && event.attendees.some((a) => !a.self)
|
||||
const [prepOpen, setPrepOpen] = useState(isPrepTarget)
|
||||
const showPrep = prepEligible && prepOpen
|
||||
const isNow = isEventNow(event)
|
||||
const platform = meetingPlatformLabel(event.conferenceLink)
|
||||
const subtitle = platform ?? event.location
|
||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||
|
||||
return (
|
||||
<div className={cn(!isLast && 'border-b')}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
|
|
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
||||
!isLast && 'border-b',
|
||||
showPrep && 'border-b',
|
||||
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
|
|
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{prepEligible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setPrepOpen((v) => !v) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-expanded={prepOpen}
|
||||
title={prepOpen ? 'Hide prep' : 'Show meeting prep'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
prepOpen ? 'bg-accent text-foreground' : 'bg-background text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
Prep
|
||||
<ChevronDown className={cn('size-3 transition-transform', prepOpen && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
|
|
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</PopoverTrigger>
|
||||
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||
</Popover>
|
||||
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
|
||||
const rows = entries
|
||||
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
|
||||
// Generated prep notes live under Meetings/prep/ — they're upcoming
|
||||
// prep, not past meeting notes, so keep them out of this table.
|
||||
.filter((entry) => !entry.path.startsWith(`${MEETINGS_ROOT}/prep/`))
|
||||
.map((entry) => {
|
||||
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
||||
const parts = relative.split('/')
|
||||
|
|
@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<UpcomingEvents />
|
||||
<UpcomingEvents onOpenNote={onOpenNote} />
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
|
|
@ -1058,7 +1313,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
className="block w-full min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
<span className="block truncate">{note.name}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ import { StepIndicator } from "./step-indicator"
|
|||
import { WelcomeStep } from "./steps/welcome-step"
|
||||
import { LlmSetupStep } from "./steps/llm-setup-step"
|
||||
import { ConnectAccountsStep } from "./steps/connect-accounts-step"
|
||||
import { CodeModeStep } from "./steps/code-mode-step"
|
||||
import { CompletionStep } from "./steps/completion-step"
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean
|
||||
onComplete: () => void
|
||||
onComplete: (opts?: { startTour?: boolean }) => void
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
|
|
@ -33,6 +34,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
case 2:
|
||||
return <ConnectAccountsStep state={state} />
|
||||
case 3:
|
||||
return <CodeModeStep state={state} />
|
||||
case 4:
|
||||
return <CompletionStep state={state} />
|
||||
}
|
||||
}, [state.currentStep, state])
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import type { Step, OnboardingPath } from "./use-onboarding-state"
|
|||
const ROWBOAT_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
interface StepIndicatorProps {
|
||||
|
|
@ -26,7 +28,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-8 px-4">
|
||||
<div className="flex items-center gap-2 mb-20 px-4">
|
||||
{steps.map((s, i) => (
|
||||
<React.Fragment key={s.step}>
|
||||
{i > 0 && (
|
||||
|
|
@ -37,7 +39,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
|
||||
|
|
@ -54,7 +56,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium transition-colors duration-300",
|
||||
"absolute top-full mt-1.5 whitespace-nowrap text-[11px] font-medium transition-colors duration-300",
|
||||
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, ArrowLeft, Terminal, CheckCircle2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { startProvisioning, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface CodeModeStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const AGENTS = [
|
||||
{ key: "claude" as const, name: "Claude Code" },
|
||||
{ key: "codex" as const, name: "Codex" },
|
||||
]
|
||||
|
||||
export function CodeModeStep({ state }: CodeModeStepProps) {
|
||||
const { handleNext, handleBack } = state
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [selected, setSelected] = useState<Record<"claude" | "codex", boolean>>({ claude: false, codex: false })
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reflect what's already set up: pre-select installed agents and turn the master
|
||||
// switch on if any agent is already there, so returning users don't start from off.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setStatusLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||
if (cancelled) return
|
||||
setStatus(result)
|
||||
const claudeInstalled = result.claude.installed
|
||||
const codexInstalled = result.codex.installed
|
||||
if (claudeInstalled || codexInstalled) {
|
||||
setEnabled(true)
|
||||
setSelected({ claude: claudeInstalled, codex: codexInstalled })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus(null)
|
||||
} finally {
|
||||
if (!cancelled) setStatusLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const onContinue = useCallback(async () => {
|
||||
if (enabled) {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: true, approvalPolicy: "ask" })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
} catch {
|
||||
// Non-fatal — the user can still enable code mode later from Settings.
|
||||
}
|
||||
setSaving(false)
|
||||
// Kick off engine downloads in the BACKGROUND for selected agents that aren't
|
||||
// installed yet. We deliberately don't block onboarding on the ~200 MB download —
|
||||
// it keeps running in the main process and its progress shows in Settings → Code Mode.
|
||||
for (const a of AGENTS) {
|
||||
if (selected[a.key] && !status?.[a.key].installed) {
|
||||
startProvisioning(a.key, () => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
handleNext()
|
||||
}, [enabled, selected, status, handleNext])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Set Up Code Mode
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center leading-relaxed mb-6 max-w-md mx-auto">
|
||||
Use your existing Claude Code or Codex subscription inside Rowboat to tackle coding
|
||||
tasks and unlock far more workflows, all without leaving the app. Make sure Claude Code
|
||||
and Codex are signed in locally with{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">claude login</code> or{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">codex login</code> in your terminal.
|
||||
</p>
|
||||
|
||||
{statusLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Master enable */}
|
||||
<div className="rounded-xl border px-4 py-3.5 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Enable code mode</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Shows the code mode chip in the composer and lets the assistant delegate to your agents.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} disabled={saving} />
|
||||
</div>
|
||||
|
||||
{/* Per-agent selection (revealed when enabled) */}
|
||||
{enabled && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Agents to set up
|
||||
</span>
|
||||
{AGENTS.map((a) => {
|
||||
const st = status?.[a.key]
|
||||
const ready = (st?.installed ?? false) && (st?.signedIn ?? false)
|
||||
return (
|
||||
<div key={a.key} className="rounded-xl border px-4 py-3 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 text-sm font-medium">{a.name}</div>
|
||||
{ready && <CheckCircle2 className="size-4 text-green-600 shrink-0" />}
|
||||
<Switch
|
||||
checked={selected[a.key]}
|
||||
onCheckedChange={(v) => setSelected((prev) => ({ ...prev, [a.key]: v }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
|
||||
<Button onClick={onContinue} size="lg" className="h-12 text-base font-medium" disabled={saving}>
|
||||
{saving ? <Loader2 className="size-5 animate-spin" /> : "Continue"}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,52 +1,46 @@
|
|||
import { CheckCircle2 } from "lucide-react"
|
||||
import { CheckCircle2, Volume2 } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TalkingHead } from "@/components/talking-head"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface CompletionStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const zeroLevel = () => 0
|
||||
|
||||
export function CompletionStep({ state }: CompletionStepProps) {
|
||||
const { connectedProviders, gmailConnected, googleCalendarConnected, handleComplete } = state
|
||||
const { connectedProviders, gmailConnected, googleCalendarConnected, handleComplete, handleCompleteWithTour } = state
|
||||
const hasConnections = connectedProviders.length > 0 || gmailConnected || googleCalendarConnected
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Animated checkmark */}
|
||||
{/* Title with checkmark on the right */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
{/* Pulsing ring */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0.6 }}
|
||||
animate={{ scale: 1.5, opacity: 0 }}
|
||||
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
|
||||
className="absolute inset-0 rounded-full bg-green-500/20"
|
||||
/>
|
||||
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
className="flex items-center gap-3 mb-3"
|
||||
>
|
||||
You're All Set!
|
||||
</motion.h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
You're All Set!
|
||||
</h2>
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.35 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CheckCircle2 className="size-9 text-green-600 dark:text-green-400" />
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8"
|
||||
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-6"
|
||||
>
|
||||
{hasConnections ? (
|
||||
<>Give me 30 minutes to build your context graph. I can still help with other things on your computer.</>
|
||||
|
|
@ -61,7 +55,7 @@ export function CompletionStep({ state }: CompletionStepProps) {
|
|||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-8"
|
||||
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-6"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-3 text-left">Connected</p>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -113,18 +107,56 @@ export function CompletionStep({ state }: CompletionStepProps) {
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
{/* Captain's tour hand-off */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
className="w-full max-w-sm rounded-xl border bg-muted/30 px-5 pt-4 pb-5 mb-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -12 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 220, damping: 18, delay: 0.65 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<TalkingHead ttsState="idle" getLevel={zeroLevel} size={88} hat="captain" />
|
||||
</motion.div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold mb-1">Want the captain's tour?</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug">
|
||||
A one-minute guided voyage through everything you just set up
|
||||
{hasConnections ? <> — perfect while I chart your data</> : null}.
|
||||
</p>
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Volume2 className="size-3.5 shrink-0" />
|
||||
<span>I narrate it out loud, so sound on!</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="flex w-full max-w-xs flex-col items-center gap-2"
|
||||
>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
onClick={handleCompleteWithTour}
|
||||
size="lg"
|
||||
className="w-full max-w-xs h-12 text-base font-medium"
|
||||
className="w-full h-12 text-base font-medium"
|
||||
>
|
||||
Start Using Rowboat
|
||||
Take the 1-minute tour
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Skip — I'll explore myself
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@ import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
OpenAIIcon,
|
||||
|
|
@ -40,16 +33,23 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
|
|||
|
||||
export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||
const {
|
||||
llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError,
|
||||
llmProvider, setLlmProvider, modelsLoading, modelsError,
|
||||
activeConfig, testState, setTestState, showApiKey,
|
||||
showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleBack,
|
||||
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
|
||||
connectedFlavors, handleNext, handleBack,
|
||||
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
|
||||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
const modelsForProvider = modelsCatalog[llmProvider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
// Hosted providers (openai/anthropic/google) get a default model, so we only
|
||||
// ask for a model on providers that truly need one (local/custom/gateway),
|
||||
// or as a fallback if no model is set yet.
|
||||
// Hosted providers (openai/anthropic/google) fetch their models from the API
|
||||
// key on test, so they never need a manual model field. Only local/custom/
|
||||
// gateway providers, where the user must specify a model, show the input.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const showModelInput = !hostedProviders.includes(llmProvider)
|
||||
|
||||
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
|
||||
const isSelected = llmProvider === provider.id
|
||||
|
|
@ -78,6 +78,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<div className="text-sm font-semibold">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{provider.description}</div>
|
||||
</div>
|
||||
{connectedFlavors.has(provider.id) && (
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
|
|
@ -87,7 +90,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Choose your model
|
||||
Choose your provider
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center mb-6">
|
||||
Select a provider and configure your API key
|
||||
|
|
@ -145,153 +148,33 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
{/* Separator */}
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
{/* Model configuration */}
|
||||
{/* Provider configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Model Configuration</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
{/* Cloud providers get a default model auto-selected; only local/custom
|
||||
providers (no catalog) need a model here. Users can pick any of the
|
||||
provider's models later in the chat view. */}
|
||||
{showModelInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Assistant Model
|
||||
Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
) : (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Knowledge Graph Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Meeting Notes Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Track Block Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -353,14 +236,23 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleTestAndSaveLlmConfig}
|
||||
variant="outline"
|
||||
onClick={handleTestAndAddAnother}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
>
|
||||
Save & add another
|
||||
</Button>
|
||||
<Button
|
||||
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
|
||||
disabled={testState.status === "testing" || (!canTest && connectedFlavors.size === 0)}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
|
||||
) : (
|
||||
) : (canTest || connectedFlavors.size === 0) ? (
|
||||
"Test & Continue"
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,15 +12,21 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Logo with ambient glow */}
|
||||
{/* Logo + main heading on the same level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative mb-8"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex items-center gap-4 mb-4"
|
||||
>
|
||||
<div className="absolute inset-0 size-16 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-16" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome to Rowboat
|
||||
</h1>
|
||||
{/* Logo with ambient glow */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 size-12 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-12" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tagline badge */}
|
||||
|
|
@ -28,21 +34,11 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
|
|||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6"
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-10"
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Your AI coworker, with memory
|
||||
</motion.div>
|
||||
|
||||
{/* Main heading */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
Welcome to Rowboat
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface ProviderState {
|
|||
isConnecting: boolean
|
||||
}
|
||||
|
||||
export type Step = 0 | 1 | 2 | 3
|
||||
export type Step = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export interface LlmModelOption {
|
|||
release_date?: string
|
||||
}
|
||||
|
||||
export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||
export function useOnboardingState(open: boolean, onComplete: (opts?: { startTour?: boolean }) => void) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0)
|
||||
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
})
|
||||
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// OAuth provider states
|
||||
|
|
@ -98,7 +99,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
|
||||
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const canTest =
|
||||
activeConfig.model.trim().length > 0 &&
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
|
|
@ -155,8 +155,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
|
|
@ -377,8 +377,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
}, [startGoogleCalendarConnect])
|
||||
|
||||
// New step flow:
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done)
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 0) {
|
||||
if (onboardingPath === 'byok') {
|
||||
|
|
@ -390,6 +390,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setCurrentStep(2)
|
||||
} else if (currentStep === 2) {
|
||||
setCurrentStep(3)
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(4)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
|
|
@ -403,50 +405,103 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
} else {
|
||||
setCurrentStep(1)
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
// Kept as no-arg handlers (rather than one that takes options) so the
|
||||
// completion step can pass them straight to onClick without the mouse
|
||||
// event leaking in as the options object.
|
||||
const handleComplete = useCallback(() => {
|
||||
onComplete()
|
||||
}, [onComplete])
|
||||
|
||||
const handleTestAndSaveLlmConfig = useCallback(async () => {
|
||||
if (!canTest) return
|
||||
const handleCompleteWithTour = useCallback(() => {
|
||||
onComplete({ startTour: true })
|
||||
}, [onComplete])
|
||||
|
||||
// Test the active provider's credentials and persist its config. Returns
|
||||
// whether it succeeded so callers can decide whether to advance or stay.
|
||||
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
|
||||
if (!canTest) return false
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
apiKey,
|
||||
baseURL,
|
||||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
liveNoteAgentModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
setTestState({ status: "success" })
|
||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
handleNext()
|
||||
} else {
|
||||
const provider = { flavor: llmProvider, apiKey, baseURL }
|
||||
|
||||
// Fetch the provider's models from the key — this both validates the
|
||||
// credentials and gives us the list to populate the chat picker.
|
||||
const result = await window.ipc.invoke("models:listForProvider", { provider })
|
||||
if (!result.success) {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const catalog: string[] = result.models ?? []
|
||||
const typed = activeConfig.model.trim()
|
||||
// Hosted providers hide the model field (it holds an auto-seeded
|
||||
// default), so only treat it as user intent where the field is shown —
|
||||
// mirrors showModelInput in llm-setup-step.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const modelInputShown = !hostedProviders.includes(llmProvider)
|
||||
|
||||
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
|
||||
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
|
||||
// so saving it would break chat at runtime with no obvious cause.
|
||||
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
|
||||
setTestState({ status: "error", error })
|
||||
toast.error(error)
|
||||
return false
|
||||
}
|
||||
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
// A model the user explicitly entered always wins — this used to prefer
|
||||
// catalog[0], which silently replaced the user's Ollama model with
|
||||
// whatever model the local server happened to list first.
|
||||
const model = modelInputShown
|
||||
? (typed || catalog[0] || "")
|
||||
: ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "")
|
||||
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
// model; users add more from Settings. Persisting the whole catalog here
|
||||
// rendered every model as a separate assistant-model row.
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
setTestState({ status: "success" })
|
||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Connection test failed:", error)
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
return false
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
|
||||
|
||||
// Save the active provider and advance to the next step.
|
||||
const handleTestAndSaveLlmConfig = useCallback(async () => {
|
||||
const ok = await testAndSaveActiveProvider()
|
||||
if (ok) handleNext()
|
||||
}, [testAndSaveActiveProvider, handleNext])
|
||||
|
||||
// Save the active provider but stay on the step. Switch to the next provider the
|
||||
// user hasn't connected yet so the form is fresh and the buttons re-enable once
|
||||
// they enter that key. (Clearing the current field instead left the buttons
|
||||
// disabled on an empty form with no clear next step.)
|
||||
const handleTestAndAddAnother = useCallback(async () => {
|
||||
const ok = await testAndSaveActiveProvider()
|
||||
if (!ok) return
|
||||
// setConnectedFlavors is async, so include the just-saved provider here.
|
||||
const connectedNow = new Set(connectedFlavors).add(llmProvider)
|
||||
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
|
||||
const next = order.find(p => !connectedNow.has(p))
|
||||
if (next) setLlmProvider(next)
|
||||
setTestState({ status: "idle" })
|
||||
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
|
|
@ -632,10 +687,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
showBaseURL,
|
||||
isLocalProvider,
|
||||
canTest,
|
||||
connectedFlavors,
|
||||
showMoreProviders,
|
||||
setShowMoreProviders,
|
||||
updateProviderConfig,
|
||||
handleTestAndSaveLlmConfig,
|
||||
handleTestAndAddAnother,
|
||||
|
||||
// OAuth state
|
||||
providers,
|
||||
|
|
@ -693,6 +750,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
handleNext,
|
||||
handleBack,
|
||||
handleComplete,
|
||||
handleCompleteWithTour,
|
||||
handleSwitchToRowboat,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
888
apps/x/apps/renderer/src/components/product-tour.tsx
Normal file
888
apps/x/apps/renderer/src/components/product-tour.tsx
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TalkingHead, type MascotHat } from '@/components/talking-head'
|
||||
import { AgentsFleet, MascotVignette, type TourVignetteKind } from '@/components/tour-vignettes'
|
||||
import { TourSounds } from '@/lib/tour-sounds'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
import tourClipWelcome from '@/assets/tour/welcome.mp3'
|
||||
import tourClipHome from '@/assets/tour/home.mp3'
|
||||
import tourClipEmail from '@/assets/tour/email.mp3'
|
||||
import tourClipMeetings from '@/assets/tour/meetings.mp3'
|
||||
import tourClipCode from '@/assets/tour/code.mp3'
|
||||
import tourClipKnowledge from '@/assets/tour/knowledge.mp3'
|
||||
import tourClipAgents from '@/assets/tour/agents.mp3'
|
||||
import tourClipApps from '@/assets/tour/apps.mp3'
|
||||
import tourClipWorkspaces from '@/assets/tour/workspaces.mp3'
|
||||
import tourClipChats from '@/assets/tour/chats.mp3'
|
||||
import tourClipComposer from '@/assets/tour/composer.mp3'
|
||||
import tourClipDone from '@/assets/tour/done.mp3'
|
||||
|
||||
export type TourNavTarget =
|
||||
| 'home'
|
||||
| 'email'
|
||||
| 'meetings'
|
||||
| 'code'
|
||||
| 'knowledge'
|
||||
| 'agents'
|
||||
| 'apps'
|
||||
| 'workspaces'
|
||||
|
||||
type TourStep = {
|
||||
id: string
|
||||
/** Matches a [data-tour-id] element. Steps whose target is absent are skipped. */
|
||||
targetId?: string
|
||||
/** View to open when the step starts, via App's navigation handlers. */
|
||||
navigate?: TourNavTarget
|
||||
/** Costume the mascot wears at this stop. */
|
||||
hat?: MascotHat
|
||||
/** Looping animation staged around the mascot while it presents this stop. */
|
||||
vignette?: TourVignetteKind
|
||||
title: string
|
||||
text: string
|
||||
/** Spoken narration, when it should differ from the bubble text. */
|
||||
voiceText?: string
|
||||
}
|
||||
|
||||
const TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'All aboard! ⚓',
|
||||
text: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time. Use Next or your arrow keys.",
|
||||
voiceText: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time.",
|
||||
},
|
||||
{
|
||||
id: 'home',
|
||||
targetId: 'nav-home',
|
||||
navigate: 'home',
|
||||
title: 'First stop: Home',
|
||||
text: 'Home is your landing spot — a quick overview of what needs your attention to get you back into the flow.',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
targetId: 'nav-email',
|
||||
navigate: 'email',
|
||||
hat: 'mailcap',
|
||||
vignette: 'email',
|
||||
title: 'Email',
|
||||
text: 'Read and triage your inbox right here. Rowboat can summarize threads, label messages, and help you draft replies.',
|
||||
},
|
||||
{
|
||||
id: 'meetings',
|
||||
targetId: 'nav-meetings',
|
||||
navigate: 'meetings',
|
||||
hat: 'headphones',
|
||||
vignette: 'meetings',
|
||||
title: 'Meetings',
|
||||
text: 'Record or join meetings, and get transcripts and notes automatically — prep briefs show up before your calls, too.',
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
targetId: 'nav-code',
|
||||
navigate: 'code',
|
||||
hat: 'hardhat',
|
||||
title: 'Code',
|
||||
text: 'The Code section runs coding agents on your projects — point one at a folder and drive it from a chat.',
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
targetId: 'nav-knowledge',
|
||||
navigate: 'knowledge',
|
||||
hat: 'gradcap',
|
||||
vignette: 'brain',
|
||||
title: 'Brain',
|
||||
text: "Brain is your knowledge base — notes, files, and everything Rowboat learns for you, all connected and searchable.",
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
targetId: 'nav-agents',
|
||||
navigate: 'agents',
|
||||
hat: 'captain',
|
||||
vignette: 'agents',
|
||||
title: 'Background agents',
|
||||
text: 'Background agents work on schedules — they keep your Brain fresh and take care of recurring tasks while you row elsewhere.',
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
targetId: 'nav-apps',
|
||||
navigate: 'apps',
|
||||
title: 'Apps',
|
||||
text: 'Apps are mini-apps you build right here in Rowboat — they get the same tools and integrations I do, and you can share them with other people. Just ask for one in chat.',
|
||||
},
|
||||
{
|
||||
id: 'workspaces',
|
||||
targetId: 'nav-workspaces',
|
||||
navigate: 'workspaces',
|
||||
hat: 'explorer',
|
||||
title: 'Workspaces',
|
||||
text: 'Workspaces hold your project folders and files, so related work stays docked together.',
|
||||
},
|
||||
{
|
||||
id: 'chats',
|
||||
targetId: 'nav-chats',
|
||||
title: 'Chats',
|
||||
text: 'Your recent conversations live here — pick any of them back up right where you left off.',
|
||||
},
|
||||
{
|
||||
id: 'composer',
|
||||
targetId: 'chat-composer',
|
||||
title: 'Talk to Rowboat',
|
||||
text: 'And this is where we talk! Type, dictate with the mic, or turn on voice output — tap my face button and I’ll read replies out loud myself.',
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
hat: 'party',
|
||||
title: "Land ho! 🎉",
|
||||
text: "That's the whole bay — and there's my wake to prove it. Take this voyage again anytime from the bottom of the sidebar. Happy rowing!",
|
||||
},
|
||||
]
|
||||
|
||||
// Pre-recorded narration bundled with the app (no TTS API call, works
|
||||
// offline/signed-out). Regenerate with scripts/generate-tour-audio.mjs after
|
||||
// editing any step's text. Steps without a clip fall back to live TTS.
|
||||
const TOUR_CLIPS: Record<string, string> = {
|
||||
welcome: tourClipWelcome,
|
||||
home: tourClipHome,
|
||||
email: tourClipEmail,
|
||||
meetings: tourClipMeetings,
|
||||
code: tourClipCode,
|
||||
knowledge: tourClipKnowledge,
|
||||
agents: tourClipAgents,
|
||||
apps: tourClipApps,
|
||||
workspaces: tourClipWorkspaces,
|
||||
chats: tourClipChats,
|
||||
composer: tourClipComposer,
|
||||
done: tourClipDone,
|
||||
}
|
||||
|
||||
const MASCOT_SIZE = 120
|
||||
const VIEWPORT_MARGIN = 16
|
||||
const BUBBLE_WIDTH = 288
|
||||
const TARGET_RESOLVE_TIMEOUT_MS = 1500
|
||||
const ZOOM_SCALE = 1.05
|
||||
const GLIDE_EASING = 'cubic-bezier(0.45, 0, 0.2, 1)'
|
||||
|
||||
type Pt = { x: number; y: number }
|
||||
type Rect = { left: number; top: number; width: number; height: number }
|
||||
type Spot = Rect & { round: boolean }
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
}
|
||||
|
||||
function quadPoint(p0: Pt, c: Pt, p1: Pt, t: number): Pt {
|
||||
const u = 1 - t
|
||||
return {
|
||||
x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x,
|
||||
y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y,
|
||||
}
|
||||
}
|
||||
|
||||
function quadPathLength(d: string): number {
|
||||
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
p.setAttribute('d', d)
|
||||
return p.getTotalLength()
|
||||
}
|
||||
|
||||
// A data-tour-id can legitimately appear on several elements (e.g. the chat
|
||||
// composer renders in both the full-screen chat and the side pane) — pick the
|
||||
// one that is actually laid out.
|
||||
function findTourTarget(targetId: string): HTMLElement | null {
|
||||
const nodes = document.querySelectorAll<HTMLElement>(`[data-tour-id="${targetId}"]`)
|
||||
for (const el of nodes) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) return el
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mascotDestForCenter(): Pt {
|
||||
return {
|
||||
x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2,
|
||||
y: window.innerHeight / 2 - MASCOT_SIZE / 2,
|
||||
}
|
||||
}
|
||||
|
||||
function mascotDestForRect(rect: Rect): Pt {
|
||||
let x: number
|
||||
let y: number
|
||||
const fitsRight = rect.left + rect.width + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth
|
||||
if (fitsRight) {
|
||||
x = rect.left + rect.width + 20
|
||||
y = rect.top + rect.height / 2 - MASCOT_SIZE / 2
|
||||
} else if (rect.top > MASCOT_SIZE + VIEWPORT_MARGIN * 2) {
|
||||
x = rect.left + rect.width / 2 - MASCOT_SIZE / 2
|
||||
y = rect.top - MASCOT_SIZE - 20
|
||||
} else {
|
||||
x = rect.left - MASCOT_SIZE - 20
|
||||
y = rect.top + rect.height / 2 - MASCOT_SIZE / 2
|
||||
}
|
||||
return {
|
||||
x: clamp(x, VIEWPORT_MARGIN, window.innerWidth - MASCOT_SIZE - VIEWPORT_MARGIN),
|
||||
y: clamp(y, VIEWPORT_MARGIN, window.innerHeight - MASCOT_SIZE - VIEWPORT_MARGIN),
|
||||
}
|
||||
}
|
||||
|
||||
function spotForRect(rect: Rect): Spot {
|
||||
return {
|
||||
left: rect.left - 6,
|
||||
top: rect.top - 6,
|
||||
width: rect.width + 12,
|
||||
height: rect.height + 12,
|
||||
round: false,
|
||||
}
|
||||
}
|
||||
|
||||
function spotForMascot(dest: Pt): Spot {
|
||||
return {
|
||||
left: dest.x - 26,
|
||||
top: dest.y - 18,
|
||||
width: MASCOT_SIZE + 52,
|
||||
height: MASCOT_SIZE + 44,
|
||||
round: true,
|
||||
}
|
||||
}
|
||||
|
||||
type ProductTourProps = {
|
||||
onClose: () => void
|
||||
onNavigate: (target: TourNavTarget) => void
|
||||
ttsAvailable: boolean
|
||||
ttsState: TTSState
|
||||
speak: (text: string) => void
|
||||
speakUrl: (url: string) => void
|
||||
cancelSpeech: () => void
|
||||
getLevel: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* The Grand Voyage: a mascot-guided walkthrough where the app dims to a
|
||||
* night-time bay, the boat rows curved routes between [data-tour-id] anchors
|
||||
* (leaving a dotted wake behind it), a spotlight and gentle camera zoom reveal
|
||||
* each section, and a parchment mini-map charts progress. Narrated with TTS
|
||||
* lip sync when available; ends in confetti.
|
||||
*
|
||||
* Rendered through a portal to <body> so the camera zoom applied to the app
|
||||
* shell never transforms the tour's own fixed-position layers.
|
||||
*/
|
||||
export function ProductTour({
|
||||
onClose,
|
||||
onNavigate,
|
||||
ttsAvailable,
|
||||
ttsState,
|
||||
speak,
|
||||
speakUrl,
|
||||
cancelSpeech,
|
||||
getLevel,
|
||||
}: ProductTourProps) {
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [arrived, setArrived] = useState(false)
|
||||
const [rowing, setRowing] = useState(false)
|
||||
const [flipped, setFlipped] = useState(false)
|
||||
const [bubbleSide, setBubbleSide] = useState<'left' | 'right'>('right')
|
||||
const [spot, setSpot] = useState<Spot | null>(null)
|
||||
const [wakes, setWakes] = useState<{ id: number; d: string }[]>([])
|
||||
const [activeWake, setActiveWake] = useState<{ d: string; len: number } | null>(null)
|
||||
const [confettiOn, setConfettiOn] = useState(false)
|
||||
const [resizeNonce, setResizeNonce] = useState(0)
|
||||
|
||||
const reducedMotion = useMemo(
|
||||
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
)
|
||||
|
||||
// Mascot position is animated by mutating the container's transform directly
|
||||
// (60fps travel without re-rendering React); posRef is the source of truth.
|
||||
const mascotElRef = useRef<HTMLDivElement>(null)
|
||||
const posRef = useRef<Pt>({
|
||||
x: window.innerWidth / 2 - MASCOT_SIZE / 2,
|
||||
y: window.innerHeight + 60,
|
||||
})
|
||||
const travelRafRef = useRef(0)
|
||||
const wakePathElRef = useRef<SVGPathElement>(null)
|
||||
const wakeIdRef = useRef(0)
|
||||
const curveSideRef = useRef(1)
|
||||
const lastSplashRef = useRef(0)
|
||||
|
||||
const directionRef = useRef(1)
|
||||
const enteredStepRef = useRef(-1)
|
||||
const stepIndexRef = useRef(stepIndex)
|
||||
|
||||
// Camera zoom state applied to the app shell (outside the portal)
|
||||
const shellRef = useRef<HTMLElement | null>(null)
|
||||
const zoomRef = useRef<{ ox: number; oy: number; s: number } | null>(null)
|
||||
|
||||
const soundsRef = useRef<TourSounds | null>(null)
|
||||
|
||||
const onCloseRef = useRef(onClose)
|
||||
const onNavigateRef = useRef(onNavigate)
|
||||
const speakRef = useRef(speak)
|
||||
const speakUrlRef = useRef(speakUrl)
|
||||
const cancelSpeechRef = useRef(cancelSpeech)
|
||||
const ttsAvailableRef = useRef(ttsAvailable)
|
||||
|
||||
// Keep latest callbacks/state in refs so the step effect and key handlers
|
||||
// stay stable. Runs before the step effect below (effect order = call order).
|
||||
useEffect(() => {
|
||||
stepIndexRef.current = stepIndex
|
||||
onCloseRef.current = onClose
|
||||
onNavigateRef.current = onNavigate
|
||||
speakRef.current = speak
|
||||
speakUrlRef.current = speakUrl
|
||||
cancelSpeechRef.current = cancelSpeech
|
||||
ttsAvailableRef.current = ttsAvailable
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (mascotElRef.current) {
|
||||
mascotElRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)`
|
||||
}
|
||||
soundsRef.current = new TourSounds()
|
||||
return () => {
|
||||
soundsRef.current?.dispose()
|
||||
soundsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Grab the app shell for the camera zoom; restore it when the tour ends
|
||||
useEffect(() => {
|
||||
const shell = document.querySelector<HTMLElement>('.rowboat-shell')
|
||||
shellRef.current = shell
|
||||
return () => {
|
||||
if (shell) {
|
||||
shell.style.transform = ''
|
||||
shell.style.transformOrigin = ''
|
||||
shell.style.transition = ''
|
||||
}
|
||||
shellRef.current = null
|
||||
zoomRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyZoom = useCallback((origin: { ox: number; oy: number } | null) => {
|
||||
const shell = shellRef.current
|
||||
if (!shell || reducedMotion) return
|
||||
if (origin) {
|
||||
// transform-origin transitions too, so moving between targets pans
|
||||
// smoothly instead of jumping when the origin changes
|
||||
shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}`
|
||||
shell.style.transformOrigin = `${origin.ox}px ${origin.oy}px`
|
||||
shell.style.transform = `scale(${ZOOM_SCALE})`
|
||||
zoomRef.current = { ox: origin.ox, oy: origin.oy, s: ZOOM_SCALE }
|
||||
} else {
|
||||
shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}`
|
||||
shell.style.transform = 'scale(1)'
|
||||
zoomRef.current = null
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
// Where the element will sit on screen once this step's zoom settles:
|
||||
// undo the current zoom mathematically, then apply the upcoming one (whose
|
||||
// origin is the target's own center, so the center never moves).
|
||||
const displayedRect = useCallback((el: HTMLElement, willZoom: boolean): { rect: Rect; origin: { ox: number; oy: number } } => {
|
||||
const m = el.getBoundingClientRect()
|
||||
const z = zoomRef.current
|
||||
let cx = m.left + m.width / 2
|
||||
let cy = m.top + m.height / 2
|
||||
let w = m.width
|
||||
let h = m.height
|
||||
if (z) {
|
||||
cx = z.ox + (cx - z.ox) / z.s
|
||||
cy = z.oy + (cy - z.oy) / z.s
|
||||
w /= z.s
|
||||
h /= z.s
|
||||
}
|
||||
const s = willZoom && !reducedMotion ? ZOOM_SCALE : 1
|
||||
return {
|
||||
rect: { left: cx - (w * s) / 2, top: cy - (h * s) / 2, width: w * s, height: h * s },
|
||||
origin: { ox: cx, oy: cy },
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
const cancelTravel = useCallback(() => {
|
||||
cancelAnimationFrame(travelRafRef.current)
|
||||
}, [])
|
||||
|
||||
const moveMascot = useCallback((p: Pt) => {
|
||||
posRef.current = p
|
||||
if (mascotElRef.current) {
|
||||
mascotElRef.current.style.transform = `translate(${p.x}px, ${p.y}px)`
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Row along a curved path from the current position, drawing the wake as we
|
||||
// go and splashing the oar; commits the wake as a dotted trail on arrival.
|
||||
const startTravel = useCallback((dest: Pt, onArrive: () => void) => {
|
||||
cancelTravel()
|
||||
const from = { ...posRef.current }
|
||||
const dist = Math.hypot(dest.x - from.x, dest.y - from.y)
|
||||
if (dist < 6 || reducedMotion) {
|
||||
moveMascot(dest)
|
||||
setRowing(false)
|
||||
onArrive()
|
||||
return
|
||||
}
|
||||
const dur = clamp(dist * 1.1, 550, 1500)
|
||||
curveSideRef.current = -curveSideRef.current
|
||||
const mx = (from.x + dest.x) / 2
|
||||
const my = (from.y + dest.y) / 2
|
||||
const nx = -(dest.y - from.y) / dist
|
||||
const ny = (dest.x - from.x) / dist
|
||||
const mag = Math.min(140, dist * 0.3) * curveSideRef.current
|
||||
const c = { x: mx + nx * mag, y: my + ny * mag }
|
||||
|
||||
// Wake follows the stern (bottom-center of the mascot box)
|
||||
const sternX = MASCOT_SIZE / 2
|
||||
const sternY = MASCOT_SIZE * 0.82
|
||||
const d = `M ${from.x + sternX} ${from.y + sternY} Q ${c.x + sternX} ${c.y + sternY} ${dest.x + sternX} ${dest.y + sternY}`
|
||||
const len = quadPathLength(d)
|
||||
setActiveWake({ d, len })
|
||||
setFlipped(dest.x < from.x - 4)
|
||||
setRowing(true)
|
||||
lastSplashRef.current = 0
|
||||
|
||||
const t0 = performance.now()
|
||||
const frame = (now: number) => {
|
||||
const raw = Math.min(1, (now - t0) / dur)
|
||||
const t = easeInOutCubic(raw)
|
||||
moveMascot(quadPoint(from, c, dest, t))
|
||||
if (wakePathElRef.current) {
|
||||
wakePathElRef.current.style.strokeDashoffset = String(len * (1 - t))
|
||||
}
|
||||
if (now - lastSplashRef.current > 420) {
|
||||
lastSplashRef.current = now
|
||||
soundsRef.current?.splash()
|
||||
}
|
||||
if (raw < 1) {
|
||||
travelRafRef.current = requestAnimationFrame(frame)
|
||||
} else {
|
||||
setRowing(false)
|
||||
setActiveWake(null)
|
||||
setWakes((ws) => [...ws, { id: wakeIdRef.current++, d }])
|
||||
onArrive()
|
||||
}
|
||||
}
|
||||
travelRafRef.current = requestAnimationFrame(frame)
|
||||
}, [cancelTravel, moveMascot, reducedMotion])
|
||||
|
||||
const playDing = useCallback(() => soundsRef.current?.ding(), [])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
cancelSpeechRef.current()
|
||||
onCloseRef.current()
|
||||
}, [])
|
||||
|
||||
const goTo = useCallback((index: number, direction: 1 | -1) => {
|
||||
directionRef.current = direction
|
||||
if (index < 0) return
|
||||
if (index >= TOUR_STEPS.length) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Silence the current step's narration right away — not on arrival —
|
||||
// so it can't talk over (or into) the next step's speech.
|
||||
cancelSpeechRef.current()
|
||||
setStepIndex(index)
|
||||
}, [finish])
|
||||
|
||||
// Stop any in-flight narration when the tour unmounts
|
||||
useEffect(() => () => cancelSpeechRef.current(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setResizeNonce((n) => n + 1)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
// Enter the current step: navigate, wait for its anchor, aim the spotlight
|
||||
// and camera, row over, then narrate. Re-runs on resize to re-anchor.
|
||||
useEffect(() => {
|
||||
const step = TOUR_STEPS[stepIndex]
|
||||
const entering = enteredStepRef.current !== stepIndex
|
||||
let cancelled = false
|
||||
|
||||
if (entering && step.navigate) {
|
||||
onNavigateRef.current(step.navigate)
|
||||
}
|
||||
|
||||
const settle = (dest: Pt, side: 'left' | 'right', spotlight: Spot, origin: { ox: number; oy: number } | null) => {
|
||||
applyZoom(origin)
|
||||
setSpot(spotlight)
|
||||
setBubbleSide(side)
|
||||
if (!entering) {
|
||||
// Resize while already at this step: jump, keep the bubble up
|
||||
cancelTravel()
|
||||
moveMascot(dest)
|
||||
return
|
||||
}
|
||||
enteredStepRef.current = stepIndex
|
||||
setArrived(false)
|
||||
startTravel(dest, () => {
|
||||
if (cancelled) return
|
||||
setArrived(true)
|
||||
soundsRef.current?.bump()
|
||||
cancelSpeechRef.current()
|
||||
const clip = TOUR_CLIPS[step.id]
|
||||
if (clip) {
|
||||
speakUrlRef.current(clip)
|
||||
} else if (ttsAvailableRef.current) {
|
||||
speakRef.current(step.voiceText ?? step.text)
|
||||
}
|
||||
if (stepIndex === TOUR_STEPS.length - 1) {
|
||||
setConfettiOn(true)
|
||||
soundsRef.current?.fanfare()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!step.targetId) {
|
||||
const dest = mascotDestForCenter()
|
||||
applyZoom(null)
|
||||
settle(dest, 'right', spotForMascot(dest), null)
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelTravel()
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
let pollRaf = 0
|
||||
const attempt = () => {
|
||||
if (cancelled) return
|
||||
const el = findTourTarget(step.targetId!)
|
||||
if (el) {
|
||||
const { rect, origin } = displayedRect(el, true)
|
||||
const dest = mascotDestForRect(rect)
|
||||
const side: 'left' | 'right' = dest.x + MASCOT_SIZE / 2 < window.innerWidth / 2 ? 'right' : 'left'
|
||||
settle(dest, side, spotForRect(rect), origin)
|
||||
return
|
||||
}
|
||||
if (performance.now() - startedAt < TARGET_RESOLVE_TIMEOUT_MS) {
|
||||
pollRaf = requestAnimationFrame(attempt)
|
||||
} else if (entering) {
|
||||
// Anchor never appeared (feature disabled / pane closed) — skip past it
|
||||
goTo(stepIndex + directionRef.current, directionRef.current as 1 | -1)
|
||||
}
|
||||
}
|
||||
attempt()
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(pollRaf)
|
||||
cancelTravel()
|
||||
}
|
||||
}, [stepIndex, resizeNonce, goTo, applyZoom, displayedRect, startTravel, cancelTravel, moveMascot])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
finish()
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
goTo(stepIndexRef.current + 1, 1)
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
goTo(stepIndexRef.current - 1, -1)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [finish, goTo])
|
||||
|
||||
const step = TOUR_STEPS[stepIndex]
|
||||
const isFirst = stepIndex === 0
|
||||
const isLast = stepIndex === TOUR_STEPS.length - 1
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes tour-bubble-in {
|
||||
0% { opacity: 0; transform: translateY(6px) scale(0.97); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes tour-wave-drift {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes tour-stamp-in {
|
||||
0% { opacity: 0; transform: scale(2); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* night falls: dim everything except the spotlight cutout */}
|
||||
{spot && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[64]"
|
||||
style={{
|
||||
left: spot.left,
|
||||
top: spot.top,
|
||||
width: spot.width,
|
||||
height: spot.height,
|
||||
borderRadius: spot.round ? 9999 : 14,
|
||||
boxShadow: '0 0 0 200vmax rgba(7, 14, 26, 0.52)',
|
||||
transition: `all 0.9s ${GLIDE_EASING}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TourWater />
|
||||
|
||||
{/* wake trails: the committed dotted route + the wake being drawn now */}
|
||||
<svg className="pointer-events-none fixed inset-0 z-[66] h-full w-full">
|
||||
{wakes.map((w) => (
|
||||
<path
|
||||
key={w.id}
|
||||
d={w.d}
|
||||
fill="none"
|
||||
stroke="#9CCBEA"
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="1 11"
|
||||
opacity={0.75}
|
||||
/>
|
||||
))}
|
||||
{activeWake && (
|
||||
<path
|
||||
ref={wakePathElRef}
|
||||
d={activeWake.d}
|
||||
fill="none"
|
||||
stroke="#BFE0F5"
|
||||
strokeWidth={5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={activeWake.len}
|
||||
strokeDashoffset={activeWake.len}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<TourMiniMap total={TOUR_STEPS.length} current={stepIndex} arrived={arrived} />
|
||||
|
||||
{/* the boat (position driven imperatively during travel) */}
|
||||
<div
|
||||
ref={mascotElRef}
|
||||
className="fixed left-0 top-0 z-[70]"
|
||||
style={{ width: MASCOT_SIZE, pointerEvents: 'none' }}
|
||||
>
|
||||
{arrived && !reducedMotion && step.vignette && step.vignette !== 'agents' && (
|
||||
<MascotVignette kind={step.vignette} playDing={playDing} />
|
||||
)}
|
||||
<div style={{ transform: flipped ? 'scaleX(-1)' : undefined, transition: 'transform 0.35s ease-in-out' }}>
|
||||
<TalkingHead ttsState={ttsState} getLevel={getLevel} size={MASCOT_SIZE} hat={step.hat} rowing={rowing} />
|
||||
</div>
|
||||
{arrived && (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute top-0 z-10 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg',
|
||||
bubbleSide === 'right' ? 'left-full ml-3' : 'right-full mr-3'
|
||||
)}
|
||||
style={{
|
||||
width: BUBBLE_WIDTH,
|
||||
animation: 'tour-bubble-in 0.25s ease-out',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="End tour"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<p className="pr-6 text-sm font-semibold">{step.title}</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{step.text}</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{stepIndex + 1} / {TOUR_STEPS.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isFirst && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2.5" onClick={() => goTo(stepIndex - 1, -1)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3"
|
||||
onClick={() => (isLast ? finish() : goTo(stepIndex + 1, 1))}
|
||||
>
|
||||
{isLast ? 'Done' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{arrived && !reducedMotion && step.vignette === 'agents' && <AgentsFleet />}
|
||||
|
||||
{confettiOn && !reducedMotion && <ConfettiBurst />}
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/** Animated translucent waves lapping at the bottom of the screen. */
|
||||
function TourWater() {
|
||||
const back = useMemo(() => wavePath(2400, 96, 8, 22), [])
|
||||
const front = useMemo(() => wavePath(2400, 96, 12, 30), [])
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[65] h-24 overflow-hidden" aria-hidden="true">
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 h-full"
|
||||
style={{ width: '200%', animation: 'tour-wave-drift 11s linear infinite' }}
|
||||
viewBox="0 0 2400 96"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={back} fill="#5F9BC9" opacity={0.3} />
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 h-full"
|
||||
style={{ width: '200%', animation: 'tour-wave-drift 7s linear infinite reverse' }}
|
||||
viewBox="0 0 2400 96"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={front} fill="#8FB6D9" opacity={0.35} />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Periodic wave: alternating up/down humps so a -50% translate loops seamlessly
|
||||
// (both hump counts are even, so half the width is a whole number of periods).
|
||||
function wavePath(width: number, height: number, humps: number, amp: number): string {
|
||||
const yTop = 34
|
||||
const seg = width / humps
|
||||
let d = `M 0 ${yTop}`
|
||||
for (let i = 0; i < humps; i++) {
|
||||
d += ` q ${seg / 2} ${i % 2 === 0 ? -amp : amp} ${seg} 0`
|
||||
}
|
||||
d += ` L ${width} ${height} L 0 ${height} Z`
|
||||
return d
|
||||
}
|
||||
|
||||
/** Parchment chart in the corner: islands per stop, dotted route, boat marker. */
|
||||
function TourMiniMap({ total, current, arrived }: { total: number; current: number; arrived: boolean }) {
|
||||
const MW = 184
|
||||
const MH = 96
|
||||
const points = useMemo(
|
||||
() =>
|
||||
Array.from({ length: total }, (_, i) => {
|
||||
const t = total === 1 ? 0 : i / (total - 1)
|
||||
return {
|
||||
x: 14 + (MW - 28) * t,
|
||||
y: MH / 2 + 4 + Math.sin(t * Math.PI * 1.5 + 0.6) * (MH * 0.26),
|
||||
}
|
||||
}),
|
||||
[total]
|
||||
)
|
||||
const route = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ')
|
||||
const boat = points[clamp(current, 0, total - 1)]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-5 left-5 z-[71] rounded-lg border-2 border-[#8A6B3D]/60 bg-[#F4E9CE] px-2 pb-1.5 pt-2 shadow-xl"
|
||||
style={{ transform: 'rotate(-1.2deg)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="mb-0.5 px-1 text-[9px] font-semibold uppercase tracking-[0.22em] text-[#6B5138]">
|
||||
Voyage chart
|
||||
</p>
|
||||
<svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`}>
|
||||
<path d={route} fill="none" stroke="#8A6B3D" strokeWidth={1.5} strokeDasharray="3 4" opacity={0.65} />
|
||||
{points.map((p, i) => {
|
||||
const visited = i < current || (i === current && arrived)
|
||||
return (
|
||||
<g key={i}>
|
||||
<ellipse cx={p.x} cy={p.y} rx={7} ry={5} fill="#DFC896" stroke="#8A6B3D" strokeWidth={1.5} />
|
||||
{visited && (
|
||||
<g style={{ animation: 'tour-stamp-in 0.3s ease-out backwards' }}>
|
||||
<line x1={p.x - 1} y1={p.y - 13} x2={p.x - 1} y2={p.y - 3} stroke="#7A4A21" strokeWidth={1.5} />
|
||||
<path d={`M ${p.x - 1} ${p.y - 13} L ${p.x + 6} ${p.y - 10.5} L ${p.x - 1} ${p.y - 8} Z`} fill="#D9534F" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* boat marker glides between islands in step with the real mascot */}
|
||||
<g style={{ transform: `translate(${boat.x}px, ${boat.y - 7}px)`, transition: `transform 0.9s ${GLIDE_EASING}` }}>
|
||||
<path d="M -7 0 Q 0 4 7 0 Q 4 6 0 6 Q -4 6 -7 0 Z" fill="#54402F" stroke="#3E2E24" strokeWidth={1} />
|
||||
<circle cx={0} cy={-3} r={3} fill="#E8E9F5" stroke="#17171B" strokeWidth={1} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two confetti cannons firing from the bottom corners, canvas-driven. */
|
||||
function ConfettiBurst() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const colors = ['#5B8DEF', '#F2B8BE', '#FFD166', '#7AC74F', '#8FB6D9', '#F2699C']
|
||||
const parts = Array.from({ length: 150 }, (_, i) => {
|
||||
const fromLeft = i % 2 === 0
|
||||
return {
|
||||
x: fromLeft ? 24 : w - 24,
|
||||
y: h - 40,
|
||||
vx: (fromLeft ? 1 : -1) * (2.5 + Math.random() * 6.5),
|
||||
vy: -(9 + Math.random() * 8),
|
||||
size: 5 + Math.random() * 5,
|
||||
color: colors[i % colors.length],
|
||||
rot: Math.random() * Math.PI,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
}
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
const t0 = performance.now()
|
||||
const frame = (now: number) => {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
for (const p of parts) {
|
||||
p.vy += 0.18
|
||||
p.vx *= 0.99
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.rot += p.vr
|
||||
ctx.save()
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate(p.rot)
|
||||
ctx.fillStyle = p.color
|
||||
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.66)
|
||||
ctx.restore()
|
||||
}
|
||||
if (now - t0 < 3200) {
|
||||
raf = requestAnimationFrame(frame)
|
||||
} else {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[72]"
|
||||
style={{ width: '100vw', height: '100vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -25,9 +25,11 @@ import { useTheme } from "@/contexts/theme-context"
|
|||
import { toast } from "sonner"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -50,6 +52,12 @@ const tabs: TabConfig[] = [
|
|||
icon: Plug,
|
||||
description: "Manage accounts and tools",
|
||||
},
|
||||
{
|
||||
id: "mobile",
|
||||
label: "Mobile",
|
||||
icon: Smartphone,
|
||||
description: "Chat with Rowboat from WhatsApp or Telegram",
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
|
|
@ -83,6 +91,12 @@ const tabs: TabConfig[] = [
|
|||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
description: "Choose which notifications you receive",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
|
|
@ -313,8 +327,8 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
|
|||
]
|
||||
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
|
|
@ -332,7 +346,7 @@ type ProviderModelConfig = {
|
|||
autoPermissionDecisionModel: string
|
||||
}
|
||||
|
||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||
|
|
@ -350,6 +364,11 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
// "Defer background tasks while a chat is running" — a top-level
|
||||
// models.json flag. deferExplicit tracks whether the user (or the Ollama
|
||||
// auto-enable) has ever set it, so we only auto-enable once.
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [deferExplicit, setDeferExplicit] = useState(false)
|
||||
|
||||
const activeConfig = providerConfigs[provider]
|
||||
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
|
||||
|
|
@ -378,43 +397,13 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
[]
|
||||
)
|
||||
|
||||
const updateModelAt = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number, value: string) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = [...prev[prov].models]
|
||||
models[index] = value
|
||||
return { ...prev, [prov]: { ...prev[prov], models } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const addModel = useCallback(
|
||||
(prov: LlmProviderFlavor) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const removeModel = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = prev[prov].models.filter((_, i) => i !== index)
|
||||
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Load current config from file
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
|
||||
|
||||
async function loadCurrentConfig() {
|
||||
try {
|
||||
setConfigLoading(true)
|
||||
|
|
@ -422,6 +411,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
path: "config/models.json",
|
||||
})
|
||||
const parsed = JSON.parse(result.data)
|
||||
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
||||
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
||||
if (parsed?.provider?.flavor && parsed?.model) {
|
||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
||||
setProvider(flavor)
|
||||
|
|
@ -440,10 +431,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
apiKey: e.apiKey || "",
|
||||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||
models: savedModels,
|
||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||
meetingNotesModel: e.meetingNotesModel || "",
|
||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
||||
knowledgeGraphModel: asString(e.knowledgeGraphModel),
|
||||
meetingNotesModel: asString(e.meetingNotesModel),
|
||||
liveNoteAgentModel: asString(e.liveNoteAgentModel),
|
||||
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -459,10 +450,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
apiKey: parsed.provider.apiKey || "",
|
||||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||
models: activeModels.length > 0 ? activeModels : [""],
|
||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
||||
knowledgeGraphModel: asString(parsed.knowledgeGraphModel),
|
||||
meetingNotesModel: asString(parsed.meetingNotesModel),
|
||||
liveNoteAgentModel: asString(parsed.liveNoteAgentModel),
|
||||
autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
|
|
@ -478,6 +469,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
loadCurrentConfig()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleDeferToggle = useCallback(async (value: boolean) => {
|
||||
setDeferBackgroundTasks(value)
|
||||
setDeferExplicit(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save setting")
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load models catalog
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
|
@ -535,10 +537,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
},
|
||||
model: allModels[0] || "",
|
||||
models: allModels,
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||
...(rowboatConnected ? {} : {
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||
}),
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -546,7 +550,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
setDefaultProvider(provider)
|
||||
setTestState({ status: "success" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
toast.success("Model configuration saved")
|
||||
// Local models compete with background agents for the same hardware:
|
||||
// when the user connects Ollama and has never touched the defer
|
||||
// flag, enable it for them (they can switch it off below).
|
||||
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
|
||||
void handleDeferToggle(true)
|
||||
}
|
||||
// Capability probe caveats (local models): saved, but the user should
|
||||
// know when the model can't do tools or has a too-small context.
|
||||
const warnings: string[] = result.warnings ?? []
|
||||
if (warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
toast.warning(warning, { duration: 12000 })
|
||||
}
|
||||
toast.success("Model configuration saved (with warnings)")
|
||||
} else {
|
||||
toast.success("Model configuration saved")
|
||||
}
|
||||
} else {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
|
|
@ -555,7 +575,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [canTest, provider, activeConfig])
|
||||
}, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
||||
|
||||
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const config = providerConfigs[prov]
|
||||
|
|
@ -570,10 +590,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
},
|
||||
model: allModels[0],
|
||||
models: allModels,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
...(rowboatConnected ? {} : {
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
setDefaultProvider(prov)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -581,7 +603,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
} catch {
|
||||
toast.error("Failed to set default provider")
|
||||
}
|
||||
}, [providerConfigs])
|
||||
}, [providerConfigs, rowboatConnected])
|
||||
|
||||
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
try {
|
||||
|
|
@ -602,10 +624,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
}
|
||||
parsed.model = defModels[0] || ""
|
||||
parsed.models = defModels
|
||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||
if (!rowboatConnected) {
|
||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||
}
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
|
|
@ -621,7 +645,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
} catch {
|
||||
toast.error("Failed to remove provider")
|
||||
}
|
||||
}, [defaultProvider, providerConfigs])
|
||||
}, [defaultProvider, providerConfigs, rowboatConnected])
|
||||
|
||||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
|
||||
const isDefault = defaultProvider === p.id
|
||||
|
|
@ -643,7 +667,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
{isDefault && (
|
||||
{isDefault && !rowboatConnected && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||
Default
|
||||
</span>
|
||||
|
|
@ -652,16 +676,18 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
||||
{!isDefault && hasModel && isSelected && (
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSetDefault(p.id)
|
||||
}}
|
||||
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
Set as default
|
||||
</span>
|
||||
{!rowboatConnected && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSetDefault(p.id)
|
||||
}}
|
||||
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
Set as default
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -713,7 +739,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Assistant models (left column) */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
|
|
@ -721,48 +747,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeConfig.models.map((model, index) => (
|
||||
<div key={index} className="group/model relative">
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => updateModelAt(provider, index, e.target.value)}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => updateModelAt(provider, index, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{activeConfig.models.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeModel(provider, index)}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addModel(provider)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add assistant model
|
||||
</button>
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={primaryModel}
|
||||
onValueChange={(value) => updateConfig(provider, { models: [value] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{modelsError && (
|
||||
|
|
@ -770,6 +777,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!rowboatConnected && (<>
|
||||
{/* Knowledge graph model (right column) */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||
|
|
@ -905,6 +913,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
@ -953,6 +962,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Defer background tasks while chatting */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
||||
</div>
|
||||
|
||||
{/* Test & Save button */}
|
||||
<Button
|
||||
onClick={handleTestAndSave}
|
||||
|
|
@ -1289,11 +1309,45 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
|
|||
}
|
||||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
//
|
||||
// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from
|
||||
// BYOK providers configured below. Values are provider-qualified
|
||||
// ("provider::model") and saved via models:updateConfig as {provider, model}
|
||||
// refs, so a signed-in user can e.g. keep the gateway assistant while
|
||||
// running background agents on a local Ollama model.
|
||||
|
||||
interface HybridModelOption {
|
||||
provider: string
|
||||
model: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
const HYBRID_SEP = "::"
|
||||
const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}`
|
||||
|
||||
function parseHybridKey(key: string): { provider: string; model: string } | null {
|
||||
const index = key.indexOf(HYBRID_SEP)
|
||||
if (index <= 0) return null
|
||||
return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) }
|
||||
}
|
||||
|
||||
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [gatewayModels, setGatewayModels] = useState<LlmModelOption[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState("")
|
||||
const [selectedKgModel, setSelectedKgModel] = useState("")
|
||||
const [options, setOptions] = useState<HybridModelOption[]>([])
|
||||
const [selectedDefault, setSelectedDefault] = useState("")
|
||||
const [selectedKg, setSelectedKg] = useState("")
|
||||
const [selectedLiveNote, setSelectedLiveNote] = useState("")
|
||||
const [selectedAutoPermission, setSelectedAutoPermission] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
|
|
@ -1303,22 +1357,63 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Fetch gateway models
|
||||
const listResult = await window.ipc.invoke("models:list", null)
|
||||
const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat")
|
||||
const models = rowboatProvider?.models || []
|
||||
setGatewayModels(models)
|
||||
const collected: HybridModelOption[] = []
|
||||
const seen = new Set<string>()
|
||||
const push = (provider: string, model: string, label?: string) => {
|
||||
if (!model) return
|
||||
const key = hybridKey(provider, model)
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
collected.push({ provider, model, label: label || model })
|
||||
}
|
||||
|
||||
// Read current selection from config
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke("models:list", null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = p.models || []
|
||||
}
|
||||
} catch { /* offline — BYOK entries below still load */ }
|
||||
for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id)
|
||||
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
const parsed = JSON.parse(configResult.data)
|
||||
if (parsed?.model) setSelectedModel(parsed.model)
|
||||
if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel)
|
||||
} catch {
|
||||
// No config yet — pick first model as default
|
||||
if (models.length > 0) setSelectedModel(models[0].id)
|
||||
parsed = JSON.parse(configResult.data)
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
|
||||
for (const [flavor, entry] of Object.entries(providersMap)) {
|
||||
const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0
|
||||
const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0
|
||||
if (!hasKey && !hasBaseURL) continue
|
||||
push(flavor, typeof entry.model === "string" ? entry.model : "")
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(flavor, m.id, m.name || m.id)
|
||||
} else {
|
||||
for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m)
|
||||
}
|
||||
}
|
||||
setOptions(collected)
|
||||
|
||||
// Current selections. Legacy string overrides pair with the BYOK
|
||||
// top-level flavor (mirrors core/models/defaults.ts).
|
||||
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
|
||||
const toKey = (value: unknown): string => {
|
||||
if (!value) return ""
|
||||
if (typeof value === "string") {
|
||||
return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : ""
|
||||
}
|
||||
const ref = value as { provider?: unknown; model?: unknown }
|
||||
return typeof ref.provider === "string" && typeof ref.model === "string"
|
||||
? hybridKey(ref.provider, ref.model)
|
||||
: ""
|
||||
}
|
||||
setSelectedDefault(toKey(parsed.defaultSelection))
|
||||
setSelectedKg(toKey(parsed.knowledgeGraphModel))
|
||||
setSelectedLiveNote(toKey(parsed.liveNoteAgentModel))
|
||||
setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel))
|
||||
} catch {
|
||||
toast.error("Failed to load models")
|
||||
} finally {
|
||||
|
|
@ -1330,13 +1425,14 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
}, [dialogOpen])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedModel) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:saveConfig", {
|
||||
provider: { flavor: "openrouter" as const },
|
||||
model: selectedModel,
|
||||
knowledgeGraphModel: selectedKgModel || undefined,
|
||||
const toRef = (key: string) => (key ? parseHybridKey(key) : null)
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
defaultSelection: toRef(selectedDefault),
|
||||
knowledgeGraphModel: toRef(selectedKg),
|
||||
liveNoteAgentModel: toRef(selectedLiveNote),
|
||||
autoPermissionDecisionModel: toRef(selectedAutoPermission),
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Model configuration saved")
|
||||
|
|
@ -1345,7 +1441,37 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [selectedModel, selectedKgModel])
|
||||
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
|
||||
|
||||
const renderSelect = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
defaultLabel: string,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">{defaultLabel}</SelectItem>
|
||||
{options.map((o) => {
|
||||
const key = hybridKey(o.provider, o.model)
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
{o.label}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[o.provider] || o.provider}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -1358,46 +1484,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the models Rowboat uses. These are provided through your Rowboat account.
|
||||
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
|
||||
</p>
|
||||
|
||||
{/* Assistant model */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Assistant model</label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gatewayModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Knowledge graph model */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Knowledge graph model</label>
|
||||
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Same as assistant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{gatewayModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")}
|
||||
{renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")}
|
||||
{renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")}
|
||||
{renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")}
|
||||
|
||||
{/* Save */}
|
||||
<Button onClick={handleSave} disabled={!selectedModel || saving}>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
|
||||
) : (
|
||||
|
|
@ -1750,55 +1846,65 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
|
||||
// --- Code Mode Settings ---
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
function AgentStatusRow({
|
||||
name,
|
||||
installLink,
|
||||
agent,
|
||||
signInCommand,
|
||||
status,
|
||||
onProvisioned,
|
||||
}: {
|
||||
name: string
|
||||
installLink: string
|
||||
agent: 'claude' | 'codex'
|
||||
signInCommand: string
|
||||
status: AgentStatus | null
|
||||
onProvisioned: () => void
|
||||
}) {
|
||||
const ready = status?.installed && status?.signedIn
|
||||
const needsSignInOnly = status?.installed && !status?.signedIn
|
||||
const prov = useProvisioning(agent)
|
||||
const provisioning = prov !== undefined && prov.error === undefined
|
||||
const error = prov?.error ?? null
|
||||
const enable = useCallback(() => startProvisioning(agent, onProvisioned), [agent, onProvisioned])
|
||||
|
||||
// Treat a just-enabled engine as installed even before the status refresh lands.
|
||||
const installed = (status?.installed ?? false) || enabledOptimistic.has(agent)
|
||||
const ready = installed && status?.signedIn
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
||||
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Installed
|
||||
<span className={cn("inline-flex items-center gap-1", installed ? "text-green-600" : "text-muted-foreground")}>
|
||||
{installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
{installed ? 'Engine ready' : 'Not enabled'}
|
||||
</span>
|
||||
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Signed in
|
||||
</span>
|
||||
</div>
|
||||
{error && <div className="text-xs text-red-600 mt-1 break-words">{error}</div>}
|
||||
</div>
|
||||
{ready ? (
|
||||
{provisioning ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{prov?.pct != null ? `${prov.pct}%` : null}
|
||||
</span>
|
||||
) : ready ? (
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Ready
|
||||
</span>
|
||||
) : needsSignInOnly ? (
|
||||
) : !installed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={enable}
|
||||
className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 shrink-0"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={installLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
Install & sign in
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1901,6 +2007,14 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
||||
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
||||
</p>
|
||||
<p>
|
||||
For each agent you want to use, you must have it{' '}
|
||||
<strong className="text-foreground">installed and logged in</strong> on this machine: click{' '}
|
||||
<strong className="text-foreground">Enable</strong> below to download its engine, and sign in by
|
||||
running <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">claude login</code>{' '}
|
||||
or <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">codex login</code>{' '}
|
||||
in your terminal. Code mode uses that saved login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1918,15 +2032,17 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
<div className="space-y-2">
|
||||
<AgentStatusRow
|
||||
name="Claude Code"
|
||||
installLink="https://claude.ai/code"
|
||||
agent="claude"
|
||||
signInCommand="claude login"
|
||||
status={status?.claude ?? null}
|
||||
onProvisioned={loadStatus}
|
||||
/>
|
||||
<AgentStatusRow
|
||||
name="Codex"
|
||||
installLink="https://developers.openai.com/codex/cli"
|
||||
agent="codex"
|
||||
signInCommand="codex login"
|
||||
status={status?.codex ?? null}
|
||||
onProvisioned={loadStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1978,8 +2094,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-amber-900 dark:text-amber-200">
|
||||
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
|
||||
account, then click Re-check.
|
||||
Neither Claude Code nor Codex is ready. Click Enable above to download an engine, sign in with a
|
||||
subscription account, then click Re-check.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1987,6 +2103,104 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Notification Settings ---
|
||||
|
||||
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task"
|
||||
|
||||
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
|
||||
{
|
||||
key: "chat_completion",
|
||||
label: "Chat responses",
|
||||
description: "When an agent finishes responding while the app is in the background.",
|
||||
},
|
||||
{
|
||||
key: "new_email",
|
||||
label: "New email",
|
||||
description: "When a new email arrives during sync while the app is in the background.",
|
||||
},
|
||||
{
|
||||
key: "agent_permission",
|
||||
label: "Permission requests",
|
||||
description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.",
|
||||
},
|
||||
{
|
||||
key: "background_task",
|
||||
label: "Background agents",
|
||||
description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.",
|
||||
},
|
||||
]
|
||||
|
||||
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [categories, setCategories] = useState<Record<NotificationCategoryKey, boolean> | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const result = await window.ipc.invoke("notifications:getSettings", null)
|
||||
if (!cancelled) setCategories(result.categories)
|
||||
} catch {
|
||||
if (!cancelled) toast.error("Failed to load notification settings")
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleToggle = useCallback(async (key: NotificationCategoryKey, next: boolean) => {
|
||||
// Optimistic update with rollback on failure.
|
||||
const previous = categories
|
||||
if (!previous) return
|
||||
const updated = { ...previous, [key]: next }
|
||||
setCategories(updated)
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("notifications:setSettings", { categories: updated })
|
||||
} catch {
|
||||
setCategories(previous)
|
||||
toast.error("Failed to update notification settings")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [categories])
|
||||
|
||||
if (!categories) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-sm text-muted-foreground leading-relaxed">
|
||||
Choose which desktop notifications Rowboat sends you. Ambient notifications are only shown
|
||||
when the app is in the background.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{NOTIFICATION_CATEGORIES.map((cat) => (
|
||||
<div key={cat.key} className="rounded-md border px-3 py-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{cat.label}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{cat.description}</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={categories[cat.key]}
|
||||
onCheckedChange={(next) => handleToggle(cat.key, next)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||
|
|
@ -2020,7 +2234,9 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
})
|
||||
}, [open])
|
||||
|
||||
const visibleTabs = useMemo(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected])
|
||||
// Hybrid mode: the Models tab is shown in both modes — signed-in users can
|
||||
// pick gateway models AND bring their own providers/models alongside.
|
||||
const visibleTabs = tabs
|
||||
|
||||
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
|
||||
const isJsonTab = activeTab === "mcp" || activeTab === "security"
|
||||
|
|
@ -2034,7 +2250,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -2142,7 +2358,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
@ -2157,14 +2373,30 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "mobile" ? (
|
||||
<MobileChannelsSettings dialogOpen={open} />
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? <RowboatModelSettings dialogOpen={open} />
|
||||
? (
|
||||
<div className="space-y-8">
|
||||
<RowboatModelSettings dialogOpen={open} />
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Your own providers</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
|
||||
</p>
|
||||
<ModelSettings dialogOpen={open} rowboatConnected />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <ModelSettings dialogOpen={open} />
|
||||
) : activeTab === "note-tagging" ? (
|
||||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "notifications" ? (
|
||||
<NotificationSettings dialogOpen={open} />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
|
|
|
|||
|
|
@ -17,17 +17,12 @@ import {
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "sonner"
|
||||
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
interface AccountSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
function formatPlanName(plan: string | null | undefined) {
|
||||
if (!plan) return 'No Plan'
|
||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
|
||||
}
|
||||
|
||||
function CreditUsageBar({ label, bucket, helper }: {
|
||||
label: string
|
||||
bucket: BillingUsageBucket
|
||||
|
|
@ -62,7 +57,8 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
|
||||
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -197,7 +193,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{formatPlanName(billing.subscriptionPlan)}
|
||||
{currentPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
||||
</p>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
|
|
@ -209,12 +205,12 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
})() : billing.subscriptionStatus ? (
|
||||
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
|
||||
) : null}
|
||||
{!billing.subscriptionPlan && (
|
||||
{!billing.subscriptionPlanId && (
|
||||
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
{!billing.subscriptionPlanId ? 'Subscribe' : currentPlan?.category === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
|
|
|
|||
|
|
@ -1,19 +1,37 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
|
||||
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { useConnectors } from "@/hooks/useConnectors"
|
||||
import { useConnectors, actionableSlackError } from "@/hooks/useConnectors"
|
||||
|
||||
interface ConnectedAccountsSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
function relativeTime(iso?: string): string {
|
||||
if (!iso) return "never"
|
||||
const then = Date.parse(iso)
|
||||
if (!Number.isFinite(then)) return "never"
|
||||
const diffSec = Math.round((Date.now() - then) / 1000)
|
||||
if (diffSec < 60) return "just now"
|
||||
const diffMin = Math.round(diffSec / 60)
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.round(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
return `${Math.round(diffHr / 24)}d ago`
|
||||
}
|
||||
|
||||
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
|
||||
const c = useConnectors(dialogOpen)
|
||||
// Windows exclusively locks Slack's Cookies DB while it runs, so we offer a
|
||||
// "quit Slack first" one-click import there. mac/Linux import with Slack open.
|
||||
const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
|
||||
|
||||
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
|
||||
const state = c.providerStates[provider] || {
|
||||
|
|
@ -237,6 +255,224 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{c.slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : c.slackEnabled && c.slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-emerald-600 truncate">
|
||||
{c.slackWorkspaces.map(workspace => workspace.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">Send messages and view channels</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{c.slackLoading || c.slackDiscovering ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : c.slackEnabled ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleSlackDisable}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleSlackEnable}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.slackPickerOpen && (
|
||||
<div className="mt-2 ml-10 space-y-2">
|
||||
{c.slackNeedsAuth ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackImportDesktop}
|
||||
disabled={c.slackAuthImporting}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.slackAuthImporting ? <Loader2 className="size-3 animate-spin" /> : "Connect Slack"}
|
||||
</Button>
|
||||
{isWindows && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleSlackQuitAndImport}
|
||||
disabled={c.slackAuthImporting}
|
||||
className="h-7 px-3 text-xs"
|
||||
title="Closes Slack so its data unlocks, then connects"
|
||||
>
|
||||
Quit Slack & connect
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => c.setSlackCurlOpen(!c.slackCurlOpen)}
|
||||
className="text-xs text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Paste from browser instead
|
||||
</button>
|
||||
</div>
|
||||
{c.slackCurlOpen && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
In a browser signed in to Slack, open DevTools → Network, click any
|
||||
request to <code>app.slack.com</code>, right-click → Copy → Copy as cURL,
|
||||
then paste it below.
|
||||
</p>
|
||||
<Textarea
|
||||
value={c.slackCurlValue}
|
||||
onChange={(event) => c.setSlackCurlValue(event.target.value)}
|
||||
placeholder="curl 'https://your-team.slack.com/api/...' -H 'Cookie: d=xoxd-...' ..."
|
||||
className="min-h-20 text-[11px] font-mono"
|
||||
disabled={c.slackCurlSubmitting}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackParseCurl}
|
||||
disabled={c.slackCurlSubmitting || c.slackCurlValue.trim().length === 0}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.slackCurlSubmitting ? <Loader2 className="size-3 animate-spin" /> : "Connect with cURL"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : c.slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{c.slackAvailableWorkspaces.map(workspace => (
|
||||
<label key={workspace.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.slackSelectedUrls.has(workspace.url)}
|
||||
onChange={(event) => {
|
||||
c.setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (event.target.checked) next.add(workspace.url)
|
||||
else next.delete(workspace.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{workspace.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackSaveWorkspaces}
|
||||
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Knowledge Sources Section */}
|
||||
{c.slackEnabled && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Knowledge Sources
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack to knowledge</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync selected channels into the knowledge graph
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={c.slackKnowledgeEnabled}
|
||||
onCheckedChange={c.setSlackKnowledgeEnabled}
|
||||
disabled={c.slackKnowledgeSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<Textarea
|
||||
value={c.slackKnowledgeChannels}
|
||||
onChange={(event) => c.setSlackKnowledgeChannels(event.target.value)}
|
||||
placeholder={c.slackWorkspaces.length > 1 ? "https://team.slack.com #engineering" : "#engineering"}
|
||||
className="min-h-20 text-xs"
|
||||
disabled={!c.slackKnowledgeEnabled || c.slackKnowledgeSaving}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
One channel per line. Use channel names or IDs.
|
||||
</span>
|
||||
{(c.slackKnowledgeDirty || c.slackKnowledgeSaving) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackKnowledgeSave}
|
||||
disabled={c.slackKnowledgeSaving || (c.slackKnowledgeEnabled && c.slackKnowledgeChannels.trim().length === 0)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.slackKnowledgeSaving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{c.slackKnowledgeEnabled && c.slackSyncStatuses.filter(s => s.enabled).map(status => (
|
||||
<div key={status.id} className="flex items-center gap-1.5 text-xs">
|
||||
{status.lastStatus === 'error' ? (
|
||||
<span className="text-amber-600 truncate">
|
||||
Sync failing — {actionableSlackError(status.lastError?.kind, status.lastError?.message)}
|
||||
</span>
|
||||
) : status.lastSyncAt ? (
|
||||
<span className="text-muted-foreground">Last synced {relativeTime(status.lastSyncAt)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not synced yet — first sync runs shortly</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { Loader2, MessageCircle, Send, Smartphone } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "sonner"
|
||||
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/src/channels.js"
|
||||
|
||||
type Config = z.infer<typeof ChannelsConfig>
|
||||
type Status = z.infer<typeof ChannelsStatus>
|
||||
|
||||
// Comma/newline separated entries; each entry keeps digits only, so a
|
||||
// formatted number like "+1 (415) 555-1234" survives as one entry instead of
|
||||
// being shattered at the spaces.
|
||||
function parseIdList(draft: string): string[] {
|
||||
return draft
|
||||
.split(/[,;\n]+/)
|
||||
.map((s) => s.replace(/\D/g, ""))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [config, setConfig] = useState<Config | null>(null)
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [tokenDraft, setTokenDraft] = useState("")
|
||||
const [waAllowDraft, setWaAllowDraft] = useState("")
|
||||
const [tgAllowDraft, setTgAllowDraft] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const [cfg, st] = await Promise.all([
|
||||
window.ipc.invoke("channels:getConfig", null),
|
||||
window.ipc.invoke("channels:getStatus", null),
|
||||
])
|
||||
if (cancelled) return
|
||||
setConfig(cfg)
|
||||
setStatus(st)
|
||||
setTokenDraft(cfg.telegram.botToken)
|
||||
setWaAllowDraft(cfg.whatsapp.allowFrom.join(", "))
|
||||
setTgAllowDraft(cfg.telegram.allowFrom.join(", "))
|
||||
} catch {
|
||||
if (!cancelled) toast.error("Failed to load mobile channel settings")
|
||||
}
|
||||
})()
|
||||
const unsubscribe = window.ipc.on("channels:status", (st) => {
|
||||
setStatus(st)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [dialogOpen])
|
||||
|
||||
const save = useCallback(async (next: Config) => {
|
||||
setConfig(next)
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("channels:setConfig", next)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save channel settings")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const wa = status?.whatsapp
|
||||
const tg = status?.telegram
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
|
||||
<Smartphone className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chat with Rowboat from your phone. Send <span className="font-mono">help</span> for
|
||||
commands (<span className="font-mono">list</span>, <span className="font-mono">resume 2</span>,{" "}
|
||||
<span className="font-mono">new</span>, <span className="font-mono">stop</span>) — anything
|
||||
else is a message to the current chat. Your computer must be on with Rowboat running.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageCircle className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">WhatsApp</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{wa?.state === "connected"
|
||||
? `Linked as +${wa.self ?? "?"} — message yourself to use it`
|
||||
: wa?.state === "qr"
|
||||
? "Scan the QR below with your phone"
|
||||
: wa?.state === "starting"
|
||||
? "Connecting…"
|
||||
: wa?.state === "error"
|
||||
? wa.error ?? "Error"
|
||||
: "Links your own WhatsApp as a companion device"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{wa?.state === "connected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke("channels:whatsappLogout", null).catch(() => {
|
||||
toast.error("Failed to unlink WhatsApp")
|
||||
})
|
||||
}}
|
||||
>
|
||||
Unlink
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
checked={config.whatsapp.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(enabled) =>
|
||||
void save({ ...config, whatsapp: { ...config.whatsapp, enabled } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.whatsapp.enabled && wa?.state === "qr" && wa.qrDataUrl && (
|
||||
<div className="flex items-center gap-4 rounded-md border p-3">
|
||||
<img src={wa.qrDataUrl} alt="WhatsApp pairing QR" className="size-40 rounded" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium text-foreground">Link Rowboat to WhatsApp</p>
|
||||
<p>1. Open WhatsApp on your phone</p>
|
||||
<p>2. Settings → Linked Devices → Link a Device</p>
|
||||
<p>3. Scan this code</p>
|
||||
<p className="pt-1">
|
||||
Then message <span className="font-medium">yourself</span> (your own contact) to talk
|
||||
to Rowboat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.whatsapp.enabled && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Additional allowed numbers</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={waAllowDraft}
|
||||
onChange={(e) => setWaAllowDraft(e.target.value)}
|
||||
placeholder="e.g. 14155551234, 919876543210"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void save({
|
||||
...config,
|
||||
whatsapp: { ...config.whatsapp, allowFrom: parseIdList(waAllowDraft) },
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your own number (self-chat) is always allowed. Digits only, with country code.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Telegram */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Send className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Telegram</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tg?.state === "polling"
|
||||
? `Listening${tg.botUsername ? ` as @${tg.botUsername}` : ""}`
|
||||
: tg?.state === "starting"
|
||||
? "Connecting…"
|
||||
: tg?.state === "error"
|
||||
? tg.error ?? "Error"
|
||||
: "Uses your own bot — create one with @BotFather"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.telegram.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(enabled) =>
|
||||
void save({ ...config, telegram: { ...config.telegram, enabled } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.telegram.enabled && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Bot token</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={tokenDraft}
|
||||
onChange={(e) => setTokenDraft(e.target.value)}
|
||||
placeholder="123456789:AAF…"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void save({
|
||||
...config,
|
||||
telegram: { ...config.telegram, botToken: tokenDraft.trim() },
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Message @BotFather on Telegram → /newbot → paste the token here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Allowed chat IDs</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tgAllowDraft}
|
||||
onChange={(e) => setTgAllowDraft(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void save({
|
||||
...config,
|
||||
telegram: { ...config.telegram, allowFrom: parseIdList(tgAllowDraft) },
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Message your bot once — it replies with your chat ID to add here.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,14 +3,17 @@
|
|||
import * as React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
FileText,
|
||||
FilePlus,
|
||||
Folder,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
Mic,
|
||||
SquarePen,
|
||||
Plug,
|
||||
|
|
@ -20,6 +23,8 @@ import {
|
|||
Settings,
|
||||
Square,
|
||||
Video,
|
||||
CircleAlert,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -49,6 +54,7 @@ import {
|
|||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
PopoverArrow,
|
||||
} from "@/components/ui/popover"
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -56,10 +62,13 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { MascotFaceIcon } from "@/components/talking-head"
|
||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "@/lib/toast"
|
||||
import { getBillingPlanData } from "@x/shared/dist/billing.js"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
import z from "zod"
|
||||
|
||||
|
|
@ -89,18 +98,6 @@ type KnowledgeActions = {
|
|||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function displayNoteName(node: TreeNode): string {
|
||||
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
|
||||
return node.name.slice(0, -3)
|
||||
}
|
||||
return node.name
|
||||
}
|
||||
|
||||
function formatBillingPlanName(plan: string | null | undefined) {
|
||||
if (!plan) return 'No plan'
|
||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan`
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const diffMs = Math.max(0, Date.now() - ms)
|
||||
const min = Math.floor(diffMs / 60000)
|
||||
|
|
@ -168,17 +165,22 @@ type SidebarContentPanelProps = {
|
|||
knowledgeActions: KnowledgeActions
|
||||
bgTaskSummaries?: TaskSummary[]
|
||||
onOpenMeetings?: () => void
|
||||
onOpenCode?: () => void
|
||||
onOpenBgTasks?: () => void
|
||||
onOpenApps?: () => void
|
||||
onOpenAgent?: (slug: string) => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
||||
onOpenRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
onOpenEmail?: (threadId?: string) => void
|
||||
onOpenHome?: () => void
|
||||
onNewChat?: () => void
|
||||
onToggleBrowser?: () => void
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
/** Starts the mascot-guided product tour. */
|
||||
onStartTour?: () => void
|
||||
/** Which primary destination is currently active, for nav highlighting. */
|
||||
activeNav?: 'home' | 'email' | 'meetings' | 'knowledge' | 'agents' | 'workspaces' | null
|
||||
activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'apps' | 'workspaces' | null
|
||||
/** Live meeting recording state, so the recording row can show its indicator/stop. */
|
||||
meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping'
|
||||
recordingMeetingSource?: string | null
|
||||
|
|
@ -412,19 +414,21 @@ function SyncStatusBar() {
|
|||
|
||||
export function SidebarContentPanel({
|
||||
tree,
|
||||
onSelectFile,
|
||||
knowledgeActions,
|
||||
bgTaskSummaries = [],
|
||||
onOpenMeetings,
|
||||
onOpenCode,
|
||||
onOpenBgTasks,
|
||||
onOpenAgent,
|
||||
onOpenApps,
|
||||
recentRuns = [],
|
||||
onOpenRun,
|
||||
onOpenChatHistory,
|
||||
onOpenEmail,
|
||||
onOpenHome,
|
||||
onNewChat,
|
||||
onToggleBrowser,
|
||||
onVoiceNoteCreated,
|
||||
onStartTour,
|
||||
activeNav,
|
||||
meetingRecordingState = 'idle',
|
||||
recordingMeetingSource = null,
|
||||
|
|
@ -437,15 +441,35 @@ export function SidebarContentPanel({
|
|||
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
|
||||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [creditPopoverOpen, setCreditPopoverOpen] = useState(false)
|
||||
const [outOfCredits, setOutOfCredits] = useState(false)
|
||||
const outOfCreditsRef = useRef(false)
|
||||
const creditPopoverAutoShownRef = useRef(false)
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing } = useBilling(isRowboatConnected)
|
||||
const { billing, refresh: refreshBilling } = useBilling(isRowboatConnected)
|
||||
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
|
||||
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
|
||||
const [unreadEmailCount, setUnreadEmailCount] = useState(0)
|
||||
const [emailThreads, setEmailThreads] = useState<SidebarEmailThread[]>([])
|
||||
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([])
|
||||
const [quickAccessExpanded, setQuickAccessExpanded] = useState(true)
|
||||
const [chatsExpanded, setChatsExpanded] = useState(true)
|
||||
// The Code section only makes sense with a coding agent available — same
|
||||
// flag the chat composer's code chip uses (auto-on when Claude Code or
|
||||
// Codex is installed + signed in; explicit toggle in settings wins).
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
window.ipc.invoke('codeMode:getConfig', null)
|
||||
.then((r) => setCodeModeEnabled(r.enabled))
|
||||
.catch(() => setCodeModeEnabled(false))
|
||||
}
|
||||
load()
|
||||
window.addEventListener('code-mode-config-changed', load)
|
||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -524,59 +548,16 @@ export function SidebarContentPanel({
|
|||
.slice(0, 10)
|
||||
}, [tree])
|
||||
|
||||
// Recents: most recently touched notes / agents / chats, interleaved by
|
||||
// recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall.
|
||||
type QuickAccessItem = {
|
||||
key: string
|
||||
label: string
|
||||
recency: number
|
||||
type: 'note' | 'agent' | 'chat'
|
||||
onClick: () => void
|
||||
}
|
||||
const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => {
|
||||
const items: QuickAccessItem[] = []
|
||||
|
||||
for (const note of recentNotes.slice(0, 4)) {
|
||||
items.push({
|
||||
key: `note:${note.path}`,
|
||||
label: displayNoteName(note),
|
||||
recency: note.stat?.mtimeMs ?? 0,
|
||||
type: 'note',
|
||||
onClick: () => onSelectFile(note.path, 'file'),
|
||||
})
|
||||
}
|
||||
|
||||
const agentRecency = (t: TaskSummary) => {
|
||||
const ts = t.lastRunAt ?? t.lastAttemptAt ?? t.createdAt
|
||||
const ms = ts ? new Date(ts).getTime() : 0
|
||||
// Chats: the 5 most recently modified chats, newest first.
|
||||
const recentChats = React.useMemo(() => {
|
||||
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
|
||||
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) {
|
||||
items.push({
|
||||
key: `agent:${t.slug}`,
|
||||
label: t.name,
|
||||
recency: agentRecency(t),
|
||||
type: 'agent',
|
||||
onClick: () => onOpenAgent?.(t.slug),
|
||||
})
|
||||
}
|
||||
|
||||
const chatRecency = (r: { createdAt: string }) => {
|
||||
const ms = new Date(r.createdAt).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) {
|
||||
items.push({
|
||||
key: `chat:${r.id}`,
|
||||
label: r.title || '(Untitled chat)',
|
||||
recency: chatRecency(r),
|
||||
type: 'chat',
|
||||
onClick: () => onOpenRun?.(r.id),
|
||||
})
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.recency - a.recency).slice(0, 12)
|
||||
}, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun])
|
||||
return [...recentRuns]
|
||||
.sort((a, b) => chatRecency(b) - chatRecency(a))
|
||||
.slice(0, 10)
|
||||
}, [recentRuns])
|
||||
|
||||
// Workspace count for the Workspaces sublabel — top-level dir children of
|
||||
// knowledge/Workspace (matches WorkspaceView's root listing).
|
||||
|
|
@ -688,6 +669,50 @@ export function SidebarContentPanel({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Re-anchor the warning whenever billing (re)loads — billing is authoritative.
|
||||
useEffect(() => {
|
||||
if (billing) {
|
||||
const next = isOutOfCredits(billing)
|
||||
outOfCreditsRef.current = next
|
||||
setOutOfCredits(next)
|
||||
}
|
||||
}, [billing])
|
||||
|
||||
// Live signals: a usage API error flips it on; a successful cost-incurring
|
||||
// call flips it off and triggers a single billing refresh to reconcile.
|
||||
useEffect(() => {
|
||||
const onExhausted = () => {
|
||||
outOfCreditsRef.current = true
|
||||
setOutOfCredits(true)
|
||||
}
|
||||
const onReplenished = () => {
|
||||
const wasOut = outOfCreditsRef.current
|
||||
outOfCreditsRef.current = false
|
||||
setOutOfCredits(false)
|
||||
if (wasOut) void refreshBilling()
|
||||
}
|
||||
window.addEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
|
||||
window.addEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
|
||||
return () => {
|
||||
window.removeEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
|
||||
window.removeEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
|
||||
}
|
||||
}, [refreshBilling])
|
||||
|
||||
// Auto-open the popover the first time we go out of credits; reset when
|
||||
// credits return so it can auto-open again on a future episode.
|
||||
useEffect(() => {
|
||||
if (outOfCredits) {
|
||||
if (!creditPopoverAutoShownRef.current) {
|
||||
creditPopoverAutoShownRef.current = true
|
||||
setCreditPopoverOpen(true)
|
||||
}
|
||||
} else {
|
||||
creditPopoverAutoShownRef.current = false
|
||||
setCreditPopoverOpen(false)
|
||||
}
|
||||
}, [outOfCredits])
|
||||
|
||||
// Single preview shown as a sublabel on the Email / Meetings nav buttons.
|
||||
const previewEmail = emailThreads[0]
|
||||
const previewMeeting = meetings[0]
|
||||
|
|
@ -729,13 +754,14 @@ export function SidebarContentPanel({
|
|||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'home'} onClick={onOpenHome}>
|
||||
<SidebarMenuButton data-tour-id="nav-home" isActive={activeNav === 'home'} onClick={onOpenHome}>
|
||||
<Home className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-email"
|
||||
isActive={activeNav === 'email'}
|
||||
onClick={() => onOpenEmail?.()}
|
||||
className={previewEmail ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -758,6 +784,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-meetings"
|
||||
isActive={activeNav === 'meetings'}
|
||||
onClick={onOpenMeetings}
|
||||
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -834,15 +861,24 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-knowledge"
|
||||
isActive={activeNav === 'knowledge'}
|
||||
onClick={() => knowledgeActions.openKnowledgeView()}
|
||||
className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined}
|
||||
>
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">Knowledge</span>
|
||||
<span className="truncate">Brain</span>
|
||||
{knowledgeUpdatedLabel && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">{knowledgeUpdatedLabel}</span>
|
||||
)}
|
||||
|
|
@ -856,6 +892,7 @@ export function SidebarContentPanel({
|
|||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-agents"
|
||||
isActive={activeNav === 'agents'}
|
||||
onClick={onOpenBgTasks}
|
||||
className={bgAgentsLabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -876,6 +913,17 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-apps"
|
||||
isActive={activeNav === 'apps'}
|
||||
onClick={onOpenApps}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-muted-foreground">Apps</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-workspaces"
|
||||
isActive={activeNav === 'workspaces'}
|
||||
onClick={() => knowledgeActions.openWorkspaceAt()}
|
||||
className="h-auto py-1.5"
|
||||
|
|
@ -895,38 +943,44 @@ export function SidebarContentPanel({
|
|||
|
||||
<div className="mx-3 border-t border-sidebar-border" />
|
||||
|
||||
{/* Recents */}
|
||||
{/* Chats */}
|
||||
<SidebarGroup className="flex flex-col">
|
||||
<SidebarGroupContent>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickAccessExpanded((v) => !v)}
|
||||
data-tour-id="nav-chats"
|
||||
onClick={() => setChatsExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
<ChevronRight className={cn('size-3 transition-transform', quickAccessExpanded && 'rotate-90')} />
|
||||
<span className="flex-1 text-left">Recents</span>
|
||||
<ChevronRight className={cn('size-3 transition-transform', chatsExpanded && 'rotate-90')} />
|
||||
<span className="flex-1 text-left">Chats</span>
|
||||
</button>
|
||||
{quickAccessExpanded && (
|
||||
quickAccessItems.length === 0 ? (
|
||||
{chatsExpanded && (
|
||||
recentChats.length === 0 ? (
|
||||
<div className="px-4 pb-2 text-[11.5px] italic text-muted-foreground">
|
||||
Recent notes and agents show up here.
|
||||
Your recent chats show up here.
|
||||
</div>
|
||||
) : (
|
||||
<SidebarMenu>
|
||||
{quickAccessItems.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton onClick={item.onClick}>
|
||||
{item.type === 'agent' ? (
|
||||
<Bot className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : item.type === 'chat' ? (
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{recentChats.map((chat) => (
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{onOpenChatHistory && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => onOpenChatHistory()}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">View all</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
)
|
||||
)}
|
||||
|
|
@ -934,31 +988,89 @@ export function SidebarContentPanel({
|
|||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{formatBillingPlanName(billing.subscriptionPlan)}
|
||||
</span>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
return (
|
||||
<p className="text-[10px] text-sidebar-foreground/60">
|
||||
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
|
||||
{isRowboatConnected && billing ? (() => {
|
||||
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
|
||||
if (outOfCredits) {
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<Popover open={creditPopoverOpen} onOpenChange={setCreditPopoverOpen}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-2">
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
<AlertTriangle className="size-4 shrink-0 text-red-500" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
||||
</span>
|
||||
<p className="text-[10px] text-red-500">Out of credits</p>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<button
|
||||
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
|
||||
>
|
||||
{upgradeLabel}
|
||||
</button>
|
||||
</div>
|
||||
<PopoverContent side="top" align="start" sideOffset={10} className="w-72">
|
||||
<PopoverArrow className="fill-popover" />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-red-500/15 text-red-500">
|
||||
<CircleAlert className="size-4" />
|
||||
</span>
|
||||
<h4 className="text-sm font-bold text-foreground">You've run out of credits</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={() => setCreditPopoverOpen(false)}
|
||||
className="rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Upgrade your plan to continue using all features.
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
<button
|
||||
onClick={() => { appUrl && window.open(`${appUrl}?intent=upgrade`); setCreditPopoverOpen(false) }}
|
||||
className="mt-3 w-full rounded-md bg-red-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
Upgrade now
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
||||
</span>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
return (
|
||||
<p className="text-[10px] text-sidebar-foreground/60">
|
||||
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
|
||||
>
|
||||
{upgradeLabel}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
|
||||
>
|
||||
{!billing.subscriptionPlan || billing.subscriptionPlan === 'free' || billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)
|
||||
})() : null}
|
||||
{/* Sign in CTA */}
|
||||
{!isRowboatConnected && (
|
||||
<div className="px-3 py-2">
|
||||
|
|
@ -1036,6 +1148,15 @@ export function SidebarContentPanel({
|
|||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
{onStartTour && (
|
||||
<button
|
||||
onClick={onStartTour}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<MascotFaceIcon className="size-4" />
|
||||
<span>Take a tour</span>
|
||||
</button>
|
||||
)}
|
||||
<SettingsDialog>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
|
||||
<Settings className="size-4" />
|
||||
|
|
|
|||
487
apps/x/apps/renderer/src/components/talking-head.tsx
Normal file
487
apps/x/apps/renderer/src/components/talking-head.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const POSITION_STORAGE_KEY = 'talking-head-position'
|
||||
|
||||
// Must match the overlay's `bottom-28 right-8` anchor classes.
|
||||
const ANCHOR_RIGHT_PX = 32
|
||||
const ANCHOR_BOTTOM_PX = 112
|
||||
const VIEWPORT_MARGIN_PX = 8
|
||||
|
||||
// Palette pulled from the mascot artwork: pale lavender body, dark walnut boat.
|
||||
const BODY_FILL = '#E8E9F5'
|
||||
const BODY_STROKE = '#17171B'
|
||||
const CHEEK_FILL = '#F2B8BE'
|
||||
const BOAT_DARK = '#3E2E24'
|
||||
const BOAT_MID = '#54402F'
|
||||
const BOAT_LIGHT = '#6B5138'
|
||||
const MOUTH_FILL = '#2A1E19'
|
||||
|
||||
export type MascotHat =
|
||||
| 'mailcap'
|
||||
| 'headphones'
|
||||
| 'hardhat'
|
||||
| 'gradcap'
|
||||
| 'captain'
|
||||
| 'explorer'
|
||||
| 'party'
|
||||
|
||||
type TalkingHeadProps = {
|
||||
ttsState: TTSState
|
||||
getLevel: () => number
|
||||
size?: number
|
||||
/** Costume piece drawn on the head (used by the product tour). */
|
||||
hat?: MascotHat
|
||||
/** Paddle hard: fast bobbing + oar strokes, e.g. while traveling in the tour. */
|
||||
rowing?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Rowboat mascot as an animated inline SVG: a round pale character sitting
|
||||
* in a wooden rowboat holding an oar. The mouth is driven every animation
|
||||
* frame from the live TTS audio level; eyes blink on a randomized timer.
|
||||
*/
|
||||
export function TalkingHead({ ttsState, getLevel, size = 160, hat, rowing = false }: TalkingHeadProps) {
|
||||
const mouthOpenRef = useRef<SVGEllipseElement>(null)
|
||||
const mouthSmileRef = useRef<SVGPathElement>(null)
|
||||
const oarRef = useRef<SVGGElement>(null)
|
||||
const smoothedRef = useRef(0)
|
||||
const [blinking, setBlinking] = useState(false)
|
||||
|
||||
const speaking = ttsState === 'speaking'
|
||||
const thinking = ttsState === 'synthesizing'
|
||||
|
||||
// Lip sync + oar paddle loop. Writes SVG attributes directly to avoid
|
||||
// re-rendering React at 60fps. Stops itself once speech has ended and the
|
||||
// mouth has settled closed; restarts when `speaking` flips this effect.
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let t = 0
|
||||
const tick = () => {
|
||||
const target = speaking ? getLevel() : 0
|
||||
const prev = smoothedRef.current
|
||||
// Fast attack, slower decay reads as natural mouth movement
|
||||
const smoothed = target > prev ? prev + (target - prev) * 0.5 : prev + (target - prev) * 0.2
|
||||
const settled = !speaking && !rowing && smoothed < 0.005
|
||||
smoothedRef.current = settled ? 0 : smoothed
|
||||
const open = settled ? 0 : Math.min(1, smoothed * 1.6)
|
||||
|
||||
const mouthOpen = mouthOpenRef.current
|
||||
const mouthSmile = mouthSmileRef.current
|
||||
if (mouthOpen && mouthSmile) {
|
||||
if (open > 0.06) {
|
||||
mouthOpen.setAttribute('rx', String(6.5 + open * 4))
|
||||
mouthOpen.setAttribute('ry', String(1.5 + open * 9))
|
||||
mouthOpen.style.opacity = '1'
|
||||
mouthSmile.style.opacity = '0'
|
||||
} else {
|
||||
mouthOpen.style.opacity = '0'
|
||||
mouthSmile.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
const oar = oarRef.current
|
||||
if (oar) {
|
||||
if (rowing) {
|
||||
t += 0.14
|
||||
const angle = Math.sin(t) * 13
|
||||
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
|
||||
} else if (speaking) {
|
||||
t += 0.045
|
||||
const angle = Math.sin(t) * 7
|
||||
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
|
||||
} else {
|
||||
oar.setAttribute('transform', 'rotate(0 128 118)')
|
||||
}
|
||||
}
|
||||
|
||||
if (!settled) {
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [speaking, rowing, getLevel])
|
||||
|
||||
// Randomized blinking
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
let cancelled = false
|
||||
const scheduleBlink = () => {
|
||||
timeout = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setBlinking(true)
|
||||
setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setBlinking(false)
|
||||
scheduleBlink()
|
||||
}, 140)
|
||||
}, 2400 + Math.random() * 2600)
|
||||
}
|
||||
scheduleBlink()
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="talking-head-bob relative select-none"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
animationDuration: rowing ? '0.8s' : speaking ? '1.6s' : '3.2s',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes talking-head-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(-1.6deg); }
|
||||
50% { transform: translateY(-4px) rotate(1.6deg); }
|
||||
}
|
||||
.talking-head-bob {
|
||||
animation-name: talking-head-bob;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
@keyframes talking-head-ripple {
|
||||
0% { transform: scale(0.6); opacity: 0.5; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
.talking-head-ripple {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: talking-head-ripple 2.6s ease-out infinite;
|
||||
}
|
||||
@keyframes talking-head-bubble {
|
||||
0%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
50% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
.talking-head-bubble {
|
||||
animation: talking-head-bubble 1.2s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<svg viewBox="0 0 200 190" width={size} height={size} aria-hidden="true">
|
||||
{/* water ripples under the boat */}
|
||||
<g>
|
||||
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '0s' }} />
|
||||
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '1.3s' }} />
|
||||
<ellipse cx="100" cy="168" rx="52" ry="7" fill="#8FB6D9" opacity="0.18" />
|
||||
</g>
|
||||
|
||||
{/* thinking bubbles while synthesizing */}
|
||||
{thinking && (
|
||||
<g fill={BODY_STROKE} opacity="0.75">
|
||||
<circle className="talking-head-bubble" cx="146" cy="34" r="3" style={{ animationDelay: '0s' }} />
|
||||
<circle className="talking-head-bubble" cx="157" cy="26" r="4.2" style={{ animationDelay: '0.2s' }} />
|
||||
<circle className="talking-head-bubble" cx="170" cy="16" r="5.4" style={{ animationDelay: '0.4s' }} />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* character: head + body blob */}
|
||||
<g>
|
||||
<path
|
||||
d="M 100 22
|
||||
C 129 22 148 43 148 68
|
||||
C 148 82 141 93 131 100
|
||||
C 141 107 147 117 148 128
|
||||
L 52 128
|
||||
C 53 115 60 105 69 99
|
||||
C 59 92 52 81 52 68
|
||||
C 52 43 71 22 100 22 Z"
|
||||
fill={BODY_FILL}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* eyes */}
|
||||
<g style={{ transform: thinking ? 'translateY(-2.5px)' : undefined, transition: 'transform 0.3s' }}>
|
||||
<ellipse
|
||||
cx="84" cy="64" rx="5" ry={blinking ? 0.8 : 7}
|
||||
fill={BODY_STROKE}
|
||||
style={{ transition: 'ry 0.06s' }}
|
||||
/>
|
||||
<ellipse
|
||||
cx="116" cy="64" rx="5" ry={blinking ? 0.8 : 7}
|
||||
fill={BODY_STROKE}
|
||||
style={{ transition: 'ry 0.06s' }}
|
||||
/>
|
||||
<circle cx="86" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
|
||||
<circle cx="118" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
|
||||
</g>
|
||||
{/* cheeks */}
|
||||
<ellipse cx="72" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
|
||||
<ellipse cx="128" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
|
||||
{/* mouth: smile when quiet, open ellipse driven by audio level */}
|
||||
<path
|
||||
ref={mouthSmileRef}
|
||||
d="M 91 80 Q 100 88 109 80"
|
||||
fill="none"
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<ellipse
|
||||
ref={mouthOpenRef}
|
||||
cx="100" cy="84" rx="7" ry="2"
|
||||
fill={MOUTH_FILL}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="3"
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
{hat && <MascotHatArt hat={hat} />}
|
||||
</g>
|
||||
|
||||
{/* oar (rotates while speaking) */}
|
||||
<g ref={oarRef}>
|
||||
<line x1="158" y1="88" x2="88" y2="152" stroke={BODY_STROKE} strokeWidth="12" strokeLinecap="round" />
|
||||
<line x1="158" y1="88" x2="88" y2="152" stroke={BOAT_MID} strokeWidth="7" strokeLinecap="round" />
|
||||
<path
|
||||
d="M 84 148 L 56 170 C 52 173 52 178 57 178 L 90 165 Z"
|
||||
fill={BOAT_DARK}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* hand resting over the oar */}
|
||||
<ellipse cx="121" cy="120" rx="10" ry="8" fill={BODY_FILL} stroke={BODY_STROKE} strokeWidth="4" />
|
||||
|
||||
{/* boat hull (drawn last so it overlaps the body) */}
|
||||
<g>
|
||||
<path
|
||||
d="M 30 120
|
||||
C 50 132 150 132 170 120
|
||||
C 168 142 152 160 100 160
|
||||
C 48 160 32 142 30 120 Z"
|
||||
fill={BOAT_MID}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* plank lines */}
|
||||
<path d="M 36 133 C 60 143 140 143 164 133" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M 44 145 C 66 153 134 153 156 145" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
{/* gunwale highlight */}
|
||||
<path d="M 33 121 C 52 131 148 131 167 121" fill="none" stroke={BOAT_LIGHT} strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TalkingHeadOverlayProps = {
|
||||
ttsState: TTSState
|
||||
getLevel: () => number
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
// Keep the widget fully on-screen relative to its bottom-right CSS anchor.
|
||||
// Falls back to the default render size when the element isn't mounted yet.
|
||||
function clampPositionToViewport(pos: { x: number; y: number }, el: HTMLDivElement | null): { x: number; y: number } {
|
||||
const width = el?.offsetWidth ?? 160
|
||||
const height = el?.offsetHeight ?? 160
|
||||
const baseLeft = window.innerWidth - ANCHOR_RIGHT_PX - width
|
||||
const baseTop = window.innerHeight - ANCHOR_BOTTOM_PX - height
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
|
||||
return {
|
||||
x: clamp(pos.x, VIEWPORT_MARGIN_PX - baseLeft, window.innerWidth - VIEWPORT_MARGIN_PX - width - baseLeft),
|
||||
y: clamp(pos.y, VIEWPORT_MARGIN_PX - baseTop, window.innerHeight - VIEWPORT_MARGIN_PX - height - baseTop),
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredPosition(): { x: number; y: number } {
|
||||
try {
|
||||
const raw = localStorage.getItem(POSITION_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt stored position
|
||||
}
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating, draggable widget that hosts the talking head. Anchored to the
|
||||
* bottom-right of the window (above the composer) and offset by a persisted
|
||||
* drag position, so it hovers over whatever view is active.
|
||||
*/
|
||||
export function TalkingHeadOverlay({ ttsState, getLevel, onDismiss }: TalkingHeadOverlayProps) {
|
||||
// Clamp the stored offset at init so a stale position (e.g. saved on a
|
||||
// bigger window) can't leave the widget stranded off-screen.
|
||||
const [offset, setOffset] = useState(() => clampPositionToViewport(loadStoredPosition(), null))
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragStartRef = useRef<{ pointerX: number; pointerY: number; x: number; y: number } | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const clampToViewport = useCallback(
|
||||
(pos: { x: number; y: number }) => clampPositionToViewport(pos, containerRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
// Re-clamp when the window shrinks
|
||||
useEffect(() => {
|
||||
const handleResize = () => setOffset(prev => clampToViewport(prev))
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [clampToViewport])
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return
|
||||
dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, x: offset.x, y: offset.y }
|
||||
setDragging(true)
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [offset])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const start = dragStartRef.current
|
||||
if (!start) return
|
||||
setOffset({
|
||||
x: start.x + (e.clientX - start.pointerX),
|
||||
y: start.y + (e.clientY - start.pointerY),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragStartRef.current) return
|
||||
dragStartRef.current = null
|
||||
setDragging(false)
|
||||
setOffset(prev => clampToViewport(prev))
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}, [clampToViewport])
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging) return
|
||||
try {
|
||||
localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(offset))
|
||||
} catch {
|
||||
// best-effort persistence
|
||||
}
|
||||
}, [offset, dragging])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'group fixed bottom-28 right-8 z-50 touch-none',
|
||||
dragging ? 'cursor-grabbing' : 'cursor-grab'
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
||||
// Constant value so the entrance animation runs once on mount and
|
||||
// never restarts (re-applying it after a drag would replay the pop).
|
||||
animation: 'talking-head-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
role="img"
|
||||
aria-label="Rowboat talking head"
|
||||
>
|
||||
<style>{`
|
||||
@keyframes talking-head-pop {
|
||||
0% { opacity: 0; scale: 0.4; }
|
||||
100% { opacity: 1; scale: 1; }
|
||||
}
|
||||
`}</style>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={onDismiss}
|
||||
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Hide talking head"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<TalkingHead ttsState={ttsState} getLevel={getLevel} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Costume pieces for the product tour, drawn on the mascot's head. */
|
||||
function MascotHatArt({ hat }: { hat: MascotHat }) {
|
||||
const outline = { stroke: BODY_STROKE, strokeWidth: 4, strokeLinejoin: 'round' as const }
|
||||
switch (hat) {
|
||||
case 'mailcap':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 68 36 Q 100 4 132 36 Q 100 26 68 36 Z" fill="#4A7DDB" {...outline} />
|
||||
<path d="M 126 33 Q 148 31 150 39 Q 132 42 124 39 Z" fill="#3D68B8" {...outline} />
|
||||
</g>
|
||||
)
|
||||
case 'headphones':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke={BODY_STROKE} strokeWidth="11" strokeLinecap="round" />
|
||||
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke="#4B5563" strokeWidth="6" strokeLinecap="round" />
|
||||
<ellipse cx="64" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
|
||||
<ellipse cx="136" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
|
||||
</g>
|
||||
)
|
||||
case 'hardhat':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 66 38 Q 100 0 134 38 Z" fill="#F6C445" {...outline} />
|
||||
<path d="M 96 10 Q 94 22 96 36 L 106 36 Q 107 22 105 9 Z" fill="#E0AC2C" stroke="none" />
|
||||
<path d="M 56 38 L 144 38" fill="none" stroke={BODY_STROKE} strokeWidth="9" strokeLinecap="round" />
|
||||
<path d="M 58 38 L 142 38" fill="none" stroke="#F6C445" strokeWidth="5" strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
case 'gradcap':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 80 28 Q 100 42 120 28 L 120 38 Q 100 50 80 38 Z" fill="#232838" {...outline} />
|
||||
<path d="M 100 2 L 144 20 L 100 38 L 56 20 Z" fill="#2E3450" {...outline} />
|
||||
<path d="M 100 20 Q 124 26 136 34" fill="none" stroke="#E8B94A" strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="137" cy="38" r="4" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
case 'captain':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 68 36 Q 100 -2 132 36 Q 100 28 68 36 Z" fill="#F4F5F9" {...outline} />
|
||||
<path d="M 66 36 Q 100 46 134 36 L 134 42 Q 100 52 66 42 Z" fill="#22262E" {...outline} />
|
||||
<path d="M 122 42 Q 146 42 148 48 Q 130 52 120 48 Z" fill="#22262E" {...outline} />
|
||||
<circle cx="100" cy="38" r="4.5" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
case 'explorer':
|
||||
return (
|
||||
<g>
|
||||
<ellipse cx="100" cy="35" rx="46" ry="9" fill="#C9A46B" {...outline} />
|
||||
<path d="M 72 34 Q 100 4 128 34 Z" fill="#C9A46B" {...outline} />
|
||||
<path d="M 76 30 Q 100 38 124 30" fill="none" stroke="#8A6B3D" strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
case 'party':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 100 -2 L 84 34 L 116 34 Z" fill="#F2699C" {...outline} />
|
||||
<path d="M 92 16 L 111 22 M 88 26 L 114 31" fill="none" stroke="#FFD166" strokeWidth="3.5" strokeLinecap="round" />
|
||||
<circle cx="100" cy="0" r="5" fill="#FFD166" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small static mascot face used as the toolbar toggle icon. */
|
||||
export function MascotFaceIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" className={className} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<ellipse cx="8.6" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
|
||||
<ellipse cx="15.4" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
|
||||
<path d="M 9 14.5 Q 12 17 15 14.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
138
apps/x/apps/renderer/src/components/tool-connections-card.tsx
Normal file
138
apps/x/apps/renderer/src/components/tool-connections-card.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowRight, Plug } from 'lucide-react'
|
||||
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
|
||||
|
||||
const TOOLKIT_PREVIEW_LIMIT = 8
|
||||
|
||||
let cachedToolkitPreviews: ToolkitPreview[] | null = null
|
||||
let cachedToolkitLogosLoaded = false
|
||||
|
||||
function ToolkitPreviewIcon({
|
||||
toolkit,
|
||||
onInvalid,
|
||||
}: {
|
||||
toolkit: ToolkitPreview
|
||||
onInvalid: (slug: string) => void
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="hidden"
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget
|
||||
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
|
||||
setLoaded(true)
|
||||
} else {
|
||||
onInvalid(toolkit.slug)
|
||||
}
|
||||
}}
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={`${toolkit.name}: ${toolkit.description}`}
|
||||
aria-label={toolkit.name}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
|
||||
>
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="size-5 shrink-0 object-contain"
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolConnectionsCard({ className }: { className?: string }) {
|
||||
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
|
||||
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
|
||||
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||
|
||||
const loadConnectorLogos = useCallback(async () => {
|
||||
if (cachedToolkitLogosLoaded) return
|
||||
try {
|
||||
const configured = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configured.configured) return
|
||||
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
|
||||
const previews = toolkits.items
|
||||
.filter((toolkit) => Boolean(toolkit.meta.logo))
|
||||
.slice(0, TOOLKIT_PREVIEW_LIMIT)
|
||||
.map((toolkit) => ({
|
||||
slug: toolkit.slug,
|
||||
logo: toolkit.meta.logo,
|
||||
name: toolkit.name,
|
||||
description: toolkit.meta.description,
|
||||
}))
|
||||
cachedToolkitPreviews = previews
|
||||
setToolkitPreviews(previews)
|
||||
} catch {
|
||||
cachedToolkitPreviews = []
|
||||
} finally {
|
||||
cachedToolkitLogosLoaded = true
|
||||
setToolkitLogosLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToolkitPreview = useCallback((slug: string) => {
|
||||
setToolkitPreviews((prev) => {
|
||||
const next = prev.filter((toolkit) => toolkit.slug !== slug)
|
||||
cachedToolkitPreviews = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadConnectorLogos()
|
||||
}, [loadConnectorLogos])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('rounded-xl border border-border bg-card p-4', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
|
||||
<Plug className="size-[14px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] leading-snug">
|
||||
<span className="text-muted-foreground">Bring context from and take action in the apps you already use.</span>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
|
||||
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
|
||||
<ToolkitPreviewIcon
|
||||
key={toolkit.slug}
|
||||
toolkit={toolkit}
|
||||
onInvalid={removeToolkitPreview}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionsSettingsOpen(true)}
|
||||
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
|
||||
>
|
||||
Connections
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsDialog
|
||||
defaultTab="connections"
|
||||
open={connectionsSettingsOpen}
|
||||
onOpenChange={setConnectionsSettingsOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
262
apps/x/apps/renderer/src/components/tour-vignettes.tsx
Normal file
262
apps/x/apps/renderer/src/components/tour-vignettes.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
export type MascotVignetteKind = 'email' | 'meetings' | 'brain'
|
||||
export type TourVignetteKind = MascotVignetteKind | 'agents'
|
||||
|
||||
/**
|
||||
* Little looping "shows" staged around the mascot while it presents a section
|
||||
* during the product tour. Purely decorative: everything is pointer-events-none
|
||||
* and rendered on the tour's own layers, never inside the section's real UI.
|
||||
*/
|
||||
export function MascotVignette({ kind, playDing }: { kind: MascotVignetteKind; playDing?: () => void }) {
|
||||
// One round of dings as the first envelopes land, then let the loop run silent
|
||||
useEffect(() => {
|
||||
if (kind !== 'email' || !playDing) return
|
||||
const timers = [900, 1900, 2900].map((ms) => setTimeout(playDing, ms))
|
||||
return () => timers.forEach(clearTimeout)
|
||||
}, [kind, playDing])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-1/2 top-0 z-0 -translate-x-1/2" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes tour-env-left {
|
||||
0% { opacity: 0; transform: translate(-150px, -130px) rotate(-26deg); }
|
||||
10% { opacity: 1; }
|
||||
52% { opacity: 1; transform: translate(0px, 0px) rotate(5deg); }
|
||||
64% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
|
||||
100% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
|
||||
}
|
||||
@keyframes tour-env-right {
|
||||
0% { opacity: 0; transform: translate(150px, -140px) rotate(26deg); }
|
||||
10% { opacity: 1; }
|
||||
52% { opacity: 1; transform: translate(0px, 0px) rotate(-5deg); }
|
||||
64% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
|
||||
100% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
|
||||
}
|
||||
@keyframes tour-badge-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
@keyframes tour-note-line {
|
||||
0% { width: 0; }
|
||||
18% { width: var(--w); }
|
||||
80% { width: var(--w); opacity: 1; }
|
||||
92%, 100% { width: var(--w); opacity: 0; }
|
||||
}
|
||||
@keyframes tour-quill-bob {
|
||||
0% { transform: translate(4px, 26px) rotate(-8deg); }
|
||||
25% { transform: translate(46px, 30px) rotate(4deg); }
|
||||
50% { transform: translate(6px, 40px) rotate(-8deg); }
|
||||
75% { transform: translate(48px, 44px) rotate(4deg); }
|
||||
100% { transform: translate(4px, 26px) rotate(-8deg); }
|
||||
}
|
||||
@keyframes tour-voice-bar {
|
||||
0%, 100% { transform: scaleY(0.35); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
@keyframes tour-orb {
|
||||
0% { opacity: 0; transform: translate(var(--from-x), var(--from-y)) scale(0.5); }
|
||||
15% { opacity: 0.9; }
|
||||
70% { opacity: 0.9; transform: translate(0, 0) scale(1); }
|
||||
85%, 100% { opacity: 0; transform: translate(0, 6px) scale(0.3); }
|
||||
}
|
||||
@keyframes tour-node-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes tour-edge-draw {
|
||||
from { stroke-dashoffset: 60; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{kind === 'email' && (
|
||||
<div className="relative" style={{ width: 240, height: 150 }}>
|
||||
{[
|
||||
{ anim: 'tour-env-left', delay: 0, x: 78, y: 96 },
|
||||
{ anim: 'tour-env-right', delay: 1.0, x: 128, y: 102 },
|
||||
{ anim: 'tour-env-left', delay: 2.0, x: 104, y: 92 },
|
||||
{ anim: 'tour-env-right', delay: 3.0, x: 90, y: 104 },
|
||||
].map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{ left: e.x, top: e.y, animation: `${e.anim} 4s ease-in-out ${e.delay}s infinite both` }}
|
||||
>
|
||||
<svg width="34" height="24" viewBox="0 0 34 24">
|
||||
<rect x="1.5" y="1.5" width="31" height="21" rx="3" fill="#FFF8E7" stroke="#17171B" strokeWidth="2.5" />
|
||||
<path d="M 2 4 L 17 14 L 32 4" fill="none" stroke="#17171B" strokeWidth="2.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: 148, top: 78, animation: 'tour-badge-pulse 2s ease-in-out infinite' }}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22">
|
||||
<circle cx="11" cy="11" r="9.5" fill="#3FA95C" stroke="#17171B" strokeWidth="2.5" />
|
||||
<path d="M 6.5 11.5 L 9.5 14.5 L 15.5 8" fill="none" stroke="#FFFFFF" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'meetings' && (
|
||||
<div className="relative" style={{ width: 220, height: 160, top: -110 }}>
|
||||
{/* someone's talking */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-end gap-1" style={{ top: 0, height: 26 }}>
|
||||
{[0.9, 1.1, 0.7, 1.3, 0.8].map((dur, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 origin-bottom rounded-full bg-[#8FB6D9]"
|
||||
style={{ height: 22, animation: `tour-voice-bar ${dur}s ease-in-out ${i * 0.12}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* ...and the notepad writes itself */}
|
||||
<div
|
||||
className="absolute left-1/2 rounded-md border-2 border-[#17171B] bg-[#FFFDF6] shadow-md"
|
||||
style={{ top: 36, width: 96, height: 104, transform: 'translateX(-50%) rotate(-3deg)' }}
|
||||
>
|
||||
<div className="mx-2 mt-2 h-1.5 rounded bg-[#D9534F]/70" />
|
||||
{[64, 52, 60, 44].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ml-2 mt-3 h-1.5 rounded bg-[#9AA1AE]"
|
||||
style={{ ['--w' as string]: `${w}px`, animation: `tour-note-line 5s ease-out ${i * 1.1}s infinite both` }}
|
||||
/>
|
||||
))}
|
||||
<svg
|
||||
className="absolute left-0 top-0"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 26 26"
|
||||
style={{ animation: 'tour-quill-bob 5s ease-in-out infinite' }}
|
||||
>
|
||||
<path d="M 4 22 L 10 12 Q 14 4 22 2 Q 18 10 12 14 Z" fill="#6B5138" stroke="#17171B" strokeWidth="2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'brain' && (
|
||||
<div className="relative" style={{ width: 260, height: 180, top: -128 }}>
|
||||
{/* constellation assembling above the head */}
|
||||
<svg className="absolute left-1/2 -translate-x-1/2" width="140" height="80" viewBox="0 0 140 80" style={{ top: 0 }}>
|
||||
{[
|
||||
'M 22 58 L 52 24',
|
||||
'M 52 24 L 88 40',
|
||||
'M 88 40 L 120 18',
|
||||
'M 88 40 L 70 66',
|
||||
].map((d, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#9CCBEA"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="60"
|
||||
style={{ animation: `tour-edge-draw 1.2s ease-out ${0.4 + i * 0.35}s both` }}
|
||||
/>
|
||||
))}
|
||||
{[
|
||||
[22, 58], [52, 24], [88, 40], [120, 18], [70, 66],
|
||||
].map(([cx, cy], i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={5}
|
||||
fill="#BFE0F5"
|
||||
stroke="#17171B"
|
||||
strokeWidth="2"
|
||||
style={{ animation: `tour-node-pulse 2.4s ease-in-out ${i * 0.3}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{/* thought-orbs drifting into the head */}
|
||||
{[
|
||||
{ fx: '-120px', fy: '-30px', delay: 0 },
|
||||
{ fx: '120px', fy: '-40px', delay: 1.1 },
|
||||
{ fx: '-90px', fy: '50px', delay: 2.2 },
|
||||
{ fx: '110px', fy: '46px', delay: 3.3 },
|
||||
].map((o, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: 122,
|
||||
top: 118,
|
||||
width: 16,
|
||||
height: 16,
|
||||
background: 'radial-gradient(circle, #FFF3B8 20%, #FFD166 60%, transparent 75%)',
|
||||
['--from-x' as string]: o.fx,
|
||||
['--from-y' as string]: o.fy,
|
||||
animation: `tour-orb 4.4s ease-in-out ${o.delay}s infinite both`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-agents vignette: a fleet of tiny mascots rowing across the water
|
||||
* at the bottom of the screen while the big one takes a break.
|
||||
*/
|
||||
export function AgentsFleet() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[67] h-28 overflow-hidden" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes tour-fleet-right {
|
||||
from { transform: translateX(-18vw); }
|
||||
to { transform: translateX(112vw); }
|
||||
}
|
||||
@keyframes tour-fleet-left {
|
||||
from { transform: translateX(112vw); }
|
||||
to { transform: translateX(-18vw); }
|
||||
}
|
||||
@keyframes tour-fleet-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||
50% { transform: translateY(-3px) rotate(2deg); }
|
||||
}
|
||||
`}</style>
|
||||
{[
|
||||
{ size: 46, bottom: 4, dur: 16, delay: 0, dir: 'tour-fleet-right' },
|
||||
{ size: 34, bottom: 22, dur: 22, delay: -8, dir: 'tour-fleet-left' },
|
||||
{ size: 28, bottom: 14, dur: 27, delay: -3, dir: 'tour-fleet-right' },
|
||||
].map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute left-0"
|
||||
style={{ bottom: b.bottom, animation: `${b.dir} ${b.dur}s linear ${b.delay}s infinite` }}
|
||||
>
|
||||
<div style={{ animation: 'tour-fleet-bob 1.4s ease-in-out infinite', transform: b.dir === 'tour-fleet-left' ? 'scaleX(-1)' : undefined }}>
|
||||
<MiniRower size={b.size} flipped={b.dir === 'tour-fleet-left'} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniRower({ size, flipped }: { size: number; flipped: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size * 0.75}
|
||||
viewBox="0 0 60 45"
|
||||
style={{ transform: flipped ? 'scaleX(-1)' : undefined }}
|
||||
>
|
||||
<circle cx="27" cy="13" r="10" fill="#E8E9F5" stroke="#17171B" strokeWidth="3" />
|
||||
<circle cx="23.5" cy="11.5" r="1.4" fill="#17171B" />
|
||||
<circle cx="30.5" cy="11.5" r="1.4" fill="#17171B" />
|
||||
<path d="M 23 16 Q 27 19 31 16" fill="none" stroke="#17171B" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<line x1="40" y1="14" x2="20" y2="38" stroke="#17171B" strokeWidth="4.5" strokeLinecap="round" />
|
||||
<line x1="40" y1="14" x2="20" y2="38" stroke="#54402F" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M 7 25 C 17 31 43 31 53 25 C 51 37 43 42 30 42 C 17 42 9 37 7 25 Z" fill="#54402F" stroke="#17171B" strokeWidth="3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,4 +43,17 @@ function PopoverAnchor({
|
|||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
function PopoverArrow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
|
||||
return (
|
||||
<PopoverPrimitive.Arrow
|
||||
data-slot="popover-arrow"
|
||||
className={cn("fill-popover", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }
|
||||
|
|
|
|||
274
apps/x/apps/renderer/src/components/video-call-view.tsx
Normal file
274
apps/x/apps/renderer/src/components/video-call-view.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Mic, MicOff, Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react'
|
||||
|
||||
import { MascotFaceIcon, TalkingHead } from '@/components/talking-head'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type VideoCallStatus = 'listening' | 'thinking' | 'speaking'
|
||||
|
||||
interface VideoCallViewProps {
|
||||
/** Live camera stream from useVideoMode — attached to the user's tile. */
|
||||
streamRef: React.MutableRefObject<MediaStream | null>
|
||||
cameraOn: boolean
|
||||
onToggleCamera: () => void
|
||||
/** User mute = full input pause: no mic audio AND no frame capture. */
|
||||
micMuted: boolean
|
||||
onToggleMic: () => void
|
||||
/** Starting a share collapses this view into the floating popout (the
|
||||
* surface is derived from devices — see App.tsx). */
|
||||
onToggleScreenShare: () => void
|
||||
/** Practice preset: the assistant is coaching this session. */
|
||||
practiceMode?: boolean
|
||||
/** Shrink to the floating pill without touching any devices. */
|
||||
onMinimize: () => void
|
||||
/** Stop the assistant: silence speech and abort the run if still going. */
|
||||
onInterrupt: () => void
|
||||
ttsState: TTSState
|
||||
/** Live TTS output level — drives the mascot's mouth animation. */
|
||||
getTtsLevel: () => number
|
||||
status: VideoCallStatus
|
||||
/** Live transcript of the user's in-progress utterance. */
|
||||
interimText?: string
|
||||
/** The assistant line currently being spoken aloud. */
|
||||
assistantCaption?: string
|
||||
onLeave: () => void
|
||||
}
|
||||
|
||||
const STATUS_DISPLAY: Record<VideoCallStatus, { label: string; dotClass: string }> = {
|
||||
listening: { label: 'Listening', dotClass: 'bg-green-500 animate-pulse' },
|
||||
thinking: { label: 'Thinking…', dotClass: 'bg-amber-400' },
|
||||
speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen call surface: a Meet-style two-tile layout with the user's
|
||||
* webcam on one side and the mascot as the other participant. Shown only
|
||||
* while the camera is on with no screen share (the derived-surface rule in
|
||||
* App.tsx) — sharing or muting the camera moves the call into the floating
|
||||
* popout. The mascot animates with the assistant's speech; dismissing it
|
||||
* swaps in a Meet-style letter avatar ("R"). Live captions run along the
|
||||
* bottom.
|
||||
*/
|
||||
export function VideoCallView({
|
||||
streamRef,
|
||||
cameraOn,
|
||||
onToggleCamera,
|
||||
micMuted,
|
||||
onToggleMic,
|
||||
onToggleScreenShare,
|
||||
practiceMode,
|
||||
onMinimize,
|
||||
onInterrupt,
|
||||
ttsState,
|
||||
getTtsLevel,
|
||||
status,
|
||||
interimText,
|
||||
assistantCaption,
|
||||
onLeave,
|
||||
}: VideoCallViewProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [mascotVisible, setMascotVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameraOn) return
|
||||
const videoEl = videoRef.current
|
||||
if (!videoEl) return
|
||||
videoEl.srcObject = streamRef.current
|
||||
videoEl.play().catch(() => {})
|
||||
return () => {
|
||||
videoEl.srcObject = null
|
||||
}
|
||||
}, [streamRef, cameraOn])
|
||||
|
||||
const userSpeaking = status === 'listening' && Boolean(interimText)
|
||||
const assistantSpeaking = ttsState === 'speaking'
|
||||
|
||||
const caption = assistantSpeaking && assistantCaption
|
||||
? { who: 'Rowboat', text: assistantCaption }
|
||||
: interimText
|
||||
? { who: 'You', text: interimText }
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col bg-neutral-950">
|
||||
{practiceMode && (
|
||||
<span className="absolute left-4 top-4 z-10 flex items-center gap-1.5 rounded-full bg-violet-600/90 px-3 py-1 text-xs font-medium text-white">
|
||||
<Presentation className="h-3.5 w-3.5" />
|
||||
Practice session
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinimize}
|
||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-white/80 transition-colors hover:bg-neutral-700 hover:text-white"
|
||||
aria-label="Minimize call (shares your screen)"
|
||||
title="Minimize — shares your screen so it can help you work"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Participant tiles */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-4 pb-2 md:grid-cols-2">
|
||||
{/* User */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 transition-shadow',
|
||||
userSpeaking && 'ring-2 ring-green-500/80'
|
||||
)}
|
||||
>
|
||||
{cameraOn ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: 'scaleX(-1)' }}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-40 w-40 items-center justify-center rounded-full bg-neutral-700 text-neutral-400" aria-label="Camera off">
|
||||
<User className="h-20 w-20" />
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
|
||||
You
|
||||
</span>
|
||||
{micMuted && (
|
||||
<span className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2 py-0.5 text-sm font-medium text-white">
|
||||
<MicOff className="h-3.5 w-3.5" />
|
||||
Muted — nothing is heard or captured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assistant */}
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 transition-shadow',
|
||||
assistantSpeaking && 'ring-2 ring-sky-400/80'
|
||||
)}
|
||||
>
|
||||
{mascotVisible ? (
|
||||
<TalkingHead ttsState={ttsState} getLevel={getTtsLevel} size={220} />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-40 w-40 items-center justify-center rounded-full bg-sky-600 text-7xl font-medium text-white"
|
||||
aria-label="Rowboat"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
|
||||
Rowboat
|
||||
</span>
|
||||
{status !== 'listening' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2.5 py-1 text-sm font-medium text-white transition-colors hover:bg-red-500"
|
||||
aria-label="Stop the assistant"
|
||||
title={status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMascotVisible((v) => !v)}
|
||||
className="absolute right-3 top-3 rounded-md bg-black/50 px-2 py-1 text-xs text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
{mascotVisible ? 'Hide mascot' : 'Show mascot'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Captions */}
|
||||
<div className="flex h-14 items-center justify-center px-6">
|
||||
{caption && (
|
||||
<div className="max-w-3xl truncate rounded-lg bg-black/60 px-4 py-2 text-sm text-white/90">
|
||||
<span className="mr-2 font-semibold text-white">{caption.who}:</span>
|
||||
{caption.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control bar */}
|
||||
<div className="flex items-center justify-center gap-4 pb-5">
|
||||
<span className="flex items-center gap-2 rounded-full bg-neutral-800 px-3 py-1.5 text-xs font-medium text-white/90">
|
||||
{/* Muted overrides "Listening" — the green pulse would be a lie.
|
||||
Thinking/speaking still show: output continues while muted. */}
|
||||
{micMuted && status === 'listening' ? (
|
||||
<>
|
||||
<span className="block h-2 w-2 rounded-full bg-red-500" />
|
||||
Muted
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn('block h-2 w-2 rounded-full', STATUS_DISPLAY[status].dotClass)} />
|
||||
{STATUS_DISPLAY[status].label}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMic}
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
|
||||
micMuted
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
|
||||
)}
|
||||
aria-label={micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
|
||||
title={micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
|
||||
>
|
||||
{micMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCamera}
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
|
||||
cameraOn
|
||||
? 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
|
||||
: 'bg-red-600 text-white hover:bg-red-500'
|
||||
)}
|
||||
aria-label={cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
title={cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
>
|
||||
{cameraOn ? <Video className="h-5 w-5" /> : <VideoOff className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleScreenShare}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-white/90 transition-colors hover:bg-neutral-700"
|
||||
aria-label="Present your screen"
|
||||
title="Present your screen"
|
||||
>
|
||||
<MonitorUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMascotVisible((v) => !v)}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-white/90 transition-colors hover:bg-neutral-700"
|
||||
aria-label={mascotVisible ? 'Hide mascot' : 'Show mascot'}
|
||||
>
|
||||
<MascotFaceIcon />
|
||||
{!mascotVisible && (
|
||||
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="block h-[1.5px] w-6 -rotate-45 rounded-full bg-white/80" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLeave}
|
||||
className="flex h-10 w-14 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-500"
|
||||
aria-label="End call"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
apps/x/apps/renderer/src/components/video-popout.tsx
Normal file
239
apps/x/apps/renderer/src/components/video-popout.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Maximize2, Mic, MicOff, MonitorUp, PhoneOff, Square, User, Video, VideoOff } from 'lucide-react'
|
||||
|
||||
import { TalkingHead } from '@/components/talking-head'
|
||||
|
||||
type PopoutState = {
|
||||
ttsState: 'idle' | 'synthesizing' | 'speaking'
|
||||
status: 'listening' | 'thinking' | 'speaking' | null
|
||||
cameraOn: boolean
|
||||
/** User mute = full input pause: no mic audio AND no frame capture. */
|
||||
micMuted: boolean
|
||||
screenSharing: boolean
|
||||
interimText: string | null
|
||||
}
|
||||
|
||||
const STATUS_DISPLAY: Record<NonNullable<PopoutState['status']>, { label: string; dotClass: string }> = {
|
||||
listening: { label: 'Listening', dotClass: 'bg-green-500 animate-pulse' },
|
||||
thinking: { label: 'Thinking…', dotClass: 'bg-amber-400' },
|
||||
speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' },
|
||||
}
|
||||
|
||||
const dragRegion = { WebkitAppRegion: 'drag' } as React.CSSProperties
|
||||
const noDragRegion = { WebkitAppRegion: 'no-drag' } as React.CSSProperties
|
||||
|
||||
/**
|
||||
* Content of the always-on-top popout window shown for the whole duration of
|
||||
* a screen share (Meet-style floating mini-call) — it floats over every app,
|
||||
* including Rowboat itself, and is the call's control surface while sharing:
|
||||
* camera toggle, share toggle, end-call. Rendered in its own BrowserWindow
|
||||
* (see `video:setPopout` in the main process); call state streams in over
|
||||
* the `video:popout-state` push channel and control actions round-trip back
|
||||
* through `video:popoutAction`. Captures its own webcam feed — MediaStreams
|
||||
* can't cross windows.
|
||||
*/
|
||||
export function VideoPopout() {
|
||||
// Camera defaults OFF: guessing "on" would flash the user's video for a
|
||||
// beat before the real state arrives — which reads as a bug. The true
|
||||
// state is fetched immediately below.
|
||||
const [state, setState] = useState<PopoutState>({ ttsState: 'idle', status: null, cameraOn: false, micMuted: false, screenSharing: false, interimText: null })
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('video:popout-state', (next) => setState(next))
|
||||
// The main process replays the cached state on did-finish-load, but that
|
||||
// can race this listener's registration — fetch it explicitly too.
|
||||
window.ipc
|
||||
.invoke('video:getPopoutState', null)
|
||||
.then(({ state: cached }) => {
|
||||
if (cached) setState(cached)
|
||||
})
|
||||
.catch(() => {})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Own camera feed, following the main window's camera-on/off state.
|
||||
useEffect(() => {
|
||||
if (!state.cameraOn) return
|
||||
let stream: MediaStream | null = null
|
||||
let cancelled = false
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: { width: { ideal: 640 }, facingMode: 'user' }, audio: false })
|
||||
.then((s) => {
|
||||
if (cancelled) {
|
||||
s.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
stream = s
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = s
|
||||
videoRef.current.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[popout] camera failed:', err))
|
||||
return () => {
|
||||
cancelled = true
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
if (videoRef.current) videoRef.current.srcObject = null
|
||||
}
|
||||
}, [state.cameraOn])
|
||||
|
||||
// The popout has no TTS audio pipeline — synthesize a plausible mouth level
|
||||
// so the mascot still animates while the assistant speaks in the main window.
|
||||
const getLevel = useCallback(() => 0.45 + 0.35 * Math.sin(performance.now() / 90), [])
|
||||
|
||||
const sendAction = useCallback((action: 'toggle-mic' | 'toggle-camera' | 'toggle-share' | 'stop-speaking' | 'end-call' | 'expand') => {
|
||||
void window.ipc.invoke('video:popoutAction', { action }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const statusDisplay = state.status ? STATUS_DISPLAY[state.status] : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-screen w-screen select-none flex-col gap-1.5 bg-neutral-900 p-1.5"
|
||||
style={dragRegion}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 gap-1.5">
|
||||
<div className="relative flex-1 overflow-hidden rounded-lg bg-neutral-800">
|
||||
{state.cameraOn ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: 'scaleX(-1)' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-700 text-neutral-400">
|
||||
<User className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute bottom-1 left-1.5 rounded bg-black/50 px-1 py-px text-[10px] text-white">
|
||||
You
|
||||
</span>
|
||||
{/* Persistent consent badge — the user must always be able to see
|
||||
at a glance that their screen is going out. Muted pauses frame
|
||||
capture while keeping the share stream open, so say so. */}
|
||||
{state.screenSharing && (
|
||||
<span className="absolute left-1.5 top-1.5 flex items-center gap-1 rounded-full bg-sky-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<span className={`block h-1.5 w-1.5 rounded-full bg-white ${state.micMuted ? '' : 'animate-pulse'}`} />
|
||||
{state.micMuted ? 'Sharing paused' : 'Sharing screen'}
|
||||
</span>
|
||||
)}
|
||||
{state.micMuted && (
|
||||
<span className="absolute bottom-1 right-1.5 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<MicOff className="h-2.5 w-2.5" />
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex flex-1 items-center justify-center overflow-hidden rounded-lg bg-neutral-800">
|
||||
<TalkingHead ttsState={state.ttsState} getLevel={getLevel} size={84} />
|
||||
<span className="absolute bottom-1 left-1.5 rounded bg-black/50 px-1 py-px text-[10px] text-white">
|
||||
Rowboat
|
||||
</span>
|
||||
{statusDisplay && (
|
||||
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded-full bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{/* Muted overrides "Listening" — the green pulse would be a lie. */}
|
||||
{state.micMuted && state.status === 'listening' ? (
|
||||
<>
|
||||
<span className="block h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
Muted
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={`block h-1.5 w-1.5 rounded-full ${statusDisplay.dotClass}`} />
|
||||
{statusDisplay.label}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{(state.status === 'speaking' || state.status === 'thinking') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('stop-speaking')}
|
||||
className="absolute bottom-1 right-1.5 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white hover:bg-red-500"
|
||||
style={noDragRegion}
|
||||
aria-label="Stop the assistant"
|
||||
title={state.status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
|
||||
>
|
||||
<Square className="h-2.5 w-2.5 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Live caption of the in-progress utterance, floating over the tiles */}
|
||||
{state.interimText && (
|
||||
<div className="pointer-events-none absolute inset-x-1.5 bottom-9 flex justify-center">
|
||||
<span className="max-w-full truncate rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-white/90">
|
||||
{state.interimText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control bar — actions execute in the main app window */}
|
||||
<div className="flex h-7 shrink-0 items-center justify-center gap-2" style={noDragRegion}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('toggle-mic')}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
state.micMuted
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
|
||||
}`}
|
||||
aria-label={state.micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
|
||||
title={state.micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
|
||||
>
|
||||
{state.micMuted ? <MicOff className="h-3.5 w-3.5" /> : <Mic className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('toggle-camera')}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
state.cameraOn
|
||||
? 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
|
||||
: 'bg-red-600 text-white hover:bg-red-500'
|
||||
}`}
|
||||
aria-label={state.cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
title={state.cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
>
|
||||
{state.cameraOn ? <Video className="h-3.5 w-3.5" /> : <VideoOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('toggle-share')}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
state.screenSharing
|
||||
? 'bg-sky-600 text-white hover:bg-sky-500'
|
||||
: 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
|
||||
}`}
|
||||
aria-label={state.screenSharing ? 'Stop sharing screen' : 'Share your screen'}
|
||||
title={state.screenSharing ? 'Stop sharing screen' : 'Share your screen'}
|
||||
>
|
||||
<MonitorUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('end-call')}
|
||||
className="flex h-6 w-8 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-500"
|
||||
aria-label="End call"
|
||||
title="End call"
|
||||
>
|
||||
<PhoneOff className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('expand')}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-700 text-white/90 transition-colors hover:bg-neutral-600"
|
||||
aria-label="Expand to full screen (stops screen sharing)"
|
||||
title="Expand to full screen (stops sharing)"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
|
|
@ -27,6 +29,7 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -55,10 +58,26 @@ type WorkspaceActions = {
|
|||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
createNote: (parentPath?: string) => void
|
||||
addGoogleDoc: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
tree: TreeNode[]
|
||||
initialPath?: string | null
|
||||
|
|
@ -68,6 +87,24 @@ type WorkspaceViewProps = {
|
|||
onNavigate: (path: string) => void
|
||||
onOpenNote: (path: string) => void
|
||||
onCreateWorkspace: (name: string) => Promise<void>
|
||||
// Opens a previous chat (run) whose work directory is set to this workspace.
|
||||
onOpenRun: (runId: string) => void
|
||||
}
|
||||
|
||||
type WorkspaceChat = {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
function formatChatAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
|
|
@ -126,9 +163,12 @@ function readFileAsBase64(file: File): Promise<string> {
|
|||
})
|
||||
}
|
||||
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onOpenRun }: WorkspaceViewProps) {
|
||||
const currentPath = initialPath || WORKSPACE_ROOT
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [chatsOpen, setChatsOpen] = useState(false)
|
||||
const [chats, setChats] = useState<WorkspaceChat[]>([])
|
||||
const [chatsLoading, setChatsLoading] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -165,6 +205,32 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
})
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
// Load the chats whose work directory is this workspace folder (or nested
|
||||
// inside it). The work directory is stored as an absolute path per run, so
|
||||
// resolve this folder against the workspace root before querying.
|
||||
const loadChats = useCallback(async () => {
|
||||
if (isRoot) {
|
||||
setChats([])
|
||||
return
|
||||
}
|
||||
setChatsLoading(true)
|
||||
try {
|
||||
const { root } = await window.ipc.invoke('workspace:getRoot', null)
|
||||
const abs = `${root.replace(/\/$/, '')}/${currentPath}`
|
||||
const { runs } = await window.ipc.invoke('runs:listByWorkDir', { dir: abs })
|
||||
setChats(runs.map((r) => ({ id: r.id, title: r.title, createdAt: r.createdAt, modifiedAt: r.modifiedAt })))
|
||||
} catch (err) {
|
||||
console.error('Failed to load workspace chats:', err)
|
||||
setChats([])
|
||||
} finally {
|
||||
setChatsLoading(false)
|
||||
}
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
useEffect(() => {
|
||||
void loadChats()
|
||||
}, [loadChats])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: TreeNode) => {
|
||||
if (renameTarget) return
|
||||
|
|
@ -335,30 +401,48 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isRoot && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={chatsOpen ? 'secondary' : 'outline'}
|
||||
onClick={() => setChatsOpen((v) => !v)}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Chats{chats.length ? ` (${chats.length})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => actions.revealInFileManager(currentPath, true)}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
Open in {fileManagerName}
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add workspace
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="w-full">
|
||||
<Button size="sm">
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => actions.createNote(currentPath)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New note
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => actions.addGoogleDoc(currentPath)}>
|
||||
<GoogleDriveIcon className="mr-2 size-4" />
|
||||
Add Google Doc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
Add files…
|
||||
|
|
@ -396,6 +480,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto px-6 py-6"
|
||||
onDragEnter={handleDragEnter}
|
||||
|
|
@ -523,6 +608,45 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!isRoot && chatsOpen && (
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">Chats</span>
|
||||
{chatsLoading && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!chatsLoading && chats.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
No chats use this workspace yet. Set a chat's work directory to this folder and it will appear here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{chats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
type="button"
|
||||
onClick={() => onOpenRun(chat.id)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-[13px] text-foreground">
|
||||
{chat.title || '(Untitled chat)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatChatAt(chat.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={addOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,58 @@ export interface ProviderStatus {
|
|||
error?: string
|
||||
}
|
||||
|
||||
type KnowledgeSourceConfig = {
|
||||
id: string
|
||||
provider: 'gmail' | 'meeting' | 'voice_memo' | 'slack' | 'github' | 'linear'
|
||||
enabled: boolean
|
||||
artifactDir: string
|
||||
syncMode: 'file' | 'poll' | 'event' | 'manual'
|
||||
intervalMs?: number
|
||||
scopes: Array<{ type: string; id: string; name?: string; workspaceUrl?: string }>
|
||||
instructions?: string
|
||||
filters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SlackSyncStatus = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
lastSyncAt?: string
|
||||
lastStatus?: 'ok' | 'error'
|
||||
lastError?: { kind: string; message: string }
|
||||
nextDueAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a structured agent-slack failure to actionable user copy. The key
|
||||
* distinction (raised by real usage): a missing Slack desktop app needs a
|
||||
* different instruction than a signed-out one.
|
||||
*/
|
||||
export function actionableSlackError(kind?: string, message?: string): string {
|
||||
// Windows locks Slack's Cookies/LevelDB files while it's running, so the
|
||||
// desktop import copy fails with EBUSY. This can surface under any kind, so
|
||||
// check the message first.
|
||||
if (message && /EBUSY|resource busy|locked|copyfile/i.test(message)) {
|
||||
return 'Slack is open and locking its data. Click "Quit Slack & connect" to close it automatically, or use "Paste from browser instead".'
|
||||
}
|
||||
switch (kind) {
|
||||
case 'not_installed':
|
||||
return 'The Slack helper is unavailable in this build. Please update or reinstall Rowboat.'
|
||||
case 'network':
|
||||
return "Couldn't reach Slack. Check your internet connection and try again."
|
||||
case 'rate_limited':
|
||||
return 'Slack is rate-limiting requests right now. Wait a minute and try again.'
|
||||
case 'bad_channel':
|
||||
return message || "A configured channel couldn't be found. Check the channel names in Settings."
|
||||
case 'not_authed':
|
||||
if (message && /Desktop data not found|not supported/i.test(message)) {
|
||||
return 'No Slack desktop app was found. Install Slack, sign in to your workspace, then click Connect.'
|
||||
}
|
||||
return 'No signed-in Slack account found. Open the Slack desktop app, sign in, then click Connect.'
|
||||
default:
|
||||
return message || "Couldn't connect to Slack. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
export function useConnectors(active: boolean) {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
|
|
@ -37,6 +89,23 @@ export function useConnectors(active: boolean) {
|
|||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
// True when discovery succeeded but no workspaces are connected yet, so the
|
||||
// user needs to import auth from the Slack desktop app (fixes the silent
|
||||
// "Enable" bounce-back where the button never progressed).
|
||||
const [slackNeedsAuth, setSlackNeedsAuth] = useState(false)
|
||||
const [slackAuthImporting, setSlackAuthImporting] = useState(false)
|
||||
// Cross-OS "paste cURL from a browser tab" fallback when desktop import fails.
|
||||
const [slackCurlOpen, setSlackCurlOpen] = useState(false)
|
||||
const [slackCurlValue, setSlackCurlValue] = useState("")
|
||||
const [slackCurlSubmitting, setSlackCurlSubmitting] = useState(false)
|
||||
const [slackKnowledgeEnabled, setSlackKnowledgeEnabled] = useState(false)
|
||||
const [slackKnowledgeChannels, setSlackKnowledgeChannels] = useState("")
|
||||
const [slackKnowledgeSaving, setSlackKnowledgeSaving] = useState(false)
|
||||
// Snapshot of the last-persisted knowledge config, used to detect unsaved
|
||||
// edits so the Save button only appears when there's something to save.
|
||||
const [slackKnowledgeSavedEnabled, setSlackKnowledgeSavedEnabled] = useState(false)
|
||||
const [slackKnowledgeSavedChannels, setSlackKnowledgeSavedChannels] = useState("")
|
||||
const [slackSyncStatuses, setSlackSyncStatuses] = useState<SlackSyncStatus[]>([])
|
||||
|
||||
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
||||
// and never flipped — the IPC that used to set them is gone. The setters
|
||||
|
|
@ -121,26 +190,105 @@ export function useConnectors(active: boolean) {
|
|||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
setSlackNeedsAuth(false)
|
||||
setSlackCurlOpen(false)
|
||||
setSlackCurlValue("")
|
||||
setSlackPickerOpen(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
if (result.workspaces.length > 0) {
|
||||
// Already-connected workspaces → straight to the picker.
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
// CLI ran but nothing is connected yet (or it errored): offer a
|
||||
// concrete next step instead of a dead-end message.
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackNeedsAuth(true)
|
||||
setSlackDiscoverError(result.error ? actionableSlackError(result.errorKind, result.error) : null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
setSlackNeedsAuth(true)
|
||||
setSlackDiscoverError("Couldn't start Slack discovery. Please try again.")
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Shared success path for both auth methods: show the discovered workspaces
|
||||
// in the picker, preselected. Returns true when workspaces were found.
|
||||
const applyDiscoveredWorkspaces = useCallback((result: { ok: boolean; workspaces: Array<{ url: string; name: string }>; error?: string; errorKind?: string }) => {
|
||||
if (result.ok && result.workspaces.length > 0) {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w) => w.url)))
|
||||
setSlackNeedsAuth(false)
|
||||
setSlackCurlOpen(false)
|
||||
setSlackCurlValue("")
|
||||
return true
|
||||
}
|
||||
setSlackDiscoverError(actionableSlackError(result.errorKind, result.error))
|
||||
return false
|
||||
}, [])
|
||||
|
||||
// Import xoxc token + cookie from the signed-in Slack desktop app, then show
|
||||
// the discovered workspaces in the picker.
|
||||
const handleSlackImportDesktop = useCallback(async () => {
|
||||
setSlackAuthImporting(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:importDesktopAuth', null)
|
||||
// Desktop import is best-effort: it fails when Slack is running and locks
|
||||
// its Cookies DB (EBUSY on Windows), or on unsupported Slack builds. On
|
||||
// any failure, reveal the browser-paste fallback so the user is never
|
||||
// stuck — it has no file-lock dependency and works cross-OS.
|
||||
if (!applyDiscoveredWorkspaces(result)) {
|
||||
setSlackCurlOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to import Slack desktop auth:', error)
|
||||
setSlackDiscoverError("Couldn't import from the Slack desktop app. Please try again, or paste from your browser below.")
|
||||
setSlackCurlOpen(true)
|
||||
} finally {
|
||||
setSlackAuthImporting(false)
|
||||
}
|
||||
}, [applyDiscoveredWorkspaces])
|
||||
|
||||
// Windows-only: force-quit Slack (releases its Cookies-DB lock) then import.
|
||||
// One click instead of the manual taskkill dance.
|
||||
const handleSlackQuitAndImport = useCallback(async () => {
|
||||
setSlackAuthImporting(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:quitAndImportDesktop', null)
|
||||
if (!applyDiscoveredWorkspaces(result)) {
|
||||
setSlackCurlOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to quit Slack and import:', error)
|
||||
setSlackDiscoverError("Couldn't import after closing Slack. Please try again, or paste from your browser below.")
|
||||
setSlackCurlOpen(true)
|
||||
} finally {
|
||||
setSlackAuthImporting(false)
|
||||
}
|
||||
}, [applyDiscoveredWorkspaces])
|
||||
|
||||
// Fallback: parse a "Copy as cURL" request pasted from a signed-in Slack web
|
||||
// tab. Works on every OS — no desktop app, leveldb, or keychain needed.
|
||||
const handleSlackParseCurl = useCallback(async () => {
|
||||
setSlackCurlSubmitting(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:parseCurlAuth', { curl: slackCurlValue })
|
||||
applyDiscoveredWorkspaces(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse Slack cURL:', error)
|
||||
setSlackDiscoverError("Couldn't read that cURL command. Please try again.")
|
||||
} finally {
|
||||
setSlackCurlSubmitting(false)
|
||||
}
|
||||
}, [applyDiscoveredWorkspaces, slackCurlValue])
|
||||
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
|
|
@ -149,6 +297,7 @@ export function useConnectors(active: boolean) {
|
|||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
setSlackNeedsAuth(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack config:', error)
|
||||
|
|
@ -165,6 +314,22 @@ export function useConnectors(active: boolean) {
|
|||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
setSlackNeedsAuth(false)
|
||||
setSlackCurlOpen(false)
|
||||
setSlackCurlValue("")
|
||||
await window.ipc.invoke('knowledgeSources:upsert', {
|
||||
id: 'slack',
|
||||
provider: 'slack',
|
||||
enabled: false,
|
||||
artifactDir: 'knowledge_sources/slack',
|
||||
syncMode: 'poll',
|
||||
intervalMs: 5 * 60 * 1000,
|
||||
scopes: [],
|
||||
})
|
||||
setSlackKnowledgeEnabled(false)
|
||||
setSlackKnowledgeChannels("")
|
||||
setSlackKnowledgeSavedEnabled(false)
|
||||
setSlackKnowledgeSavedChannels("")
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
|
|
@ -174,6 +339,93 @@ export function useConnectors(active: boolean) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const refreshSlackKnowledgeStatus = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:knowledgeStatus', null)
|
||||
setSlackSyncStatuses(result.sources)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack knowledge status:', error)
|
||||
setSlackSyncStatuses([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshKnowledgeSources = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('knowledgeSources:getConfig', null)
|
||||
const slackSource = (result.sources as KnowledgeSourceConfig[]).find(source => source.id === 'slack')
|
||||
const enabled = Boolean(slackSource?.enabled)
|
||||
const channels = (slackSource?.scopes ?? [])
|
||||
.filter(scope => scope.type === 'channel')
|
||||
.map(scope => {
|
||||
const channel = scope.name || scope.id
|
||||
return scope.workspaceUrl ? `${scope.workspaceUrl} ${channel}` : channel
|
||||
})
|
||||
.join('\n')
|
||||
setSlackKnowledgeEnabled(enabled)
|
||||
setSlackKnowledgeChannels(channels)
|
||||
setSlackKnowledgeSavedEnabled(enabled)
|
||||
setSlackKnowledgeSavedChannels(channels)
|
||||
} catch (error) {
|
||||
console.error('Failed to load knowledge sources:', error)
|
||||
setSlackKnowledgeEnabled(false)
|
||||
setSlackKnowledgeChannels("")
|
||||
setSlackKnowledgeSavedEnabled(false)
|
||||
setSlackKnowledgeSavedChannels("")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const parseSlackKnowledgeScopes = useCallback(() => {
|
||||
const defaultWorkspaceUrl = slackWorkspaces.length === 1 ? slackWorkspaces[0]?.url : undefined
|
||||
return slackKnowledgeChannels
|
||||
.split(/\n+/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const parts = line.split(/\s+/)
|
||||
const first = parts[0] ?? ''
|
||||
const hasWorkspace = /^https?:\/\//.test(first)
|
||||
const workspaceUrl = hasWorkspace ? first : defaultWorkspaceUrl
|
||||
const channelRaw = hasWorkspace ? parts.slice(1).join(' ') : line
|
||||
const channel = channelRaw.trim()
|
||||
return {
|
||||
type: 'channel',
|
||||
id: channel.replace(/^#/, ''),
|
||||
name: channel.startsWith('#') ? channel : `#${channel}`,
|
||||
workspaceUrl,
|
||||
}
|
||||
})
|
||||
.filter(scope => scope.id.length > 0)
|
||||
}, [slackKnowledgeChannels, slackWorkspaces])
|
||||
|
||||
const handleSlackKnowledgeSave = useCallback(async () => {
|
||||
try {
|
||||
setSlackKnowledgeSaving(true)
|
||||
const scopes = parseSlackKnowledgeScopes()
|
||||
await window.ipc.invoke('knowledgeSources:upsert', {
|
||||
id: 'slack',
|
||||
provider: 'slack',
|
||||
enabled: slackKnowledgeEnabled && scopes.length > 0,
|
||||
artifactDir: 'knowledge_sources/slack',
|
||||
syncMode: 'poll',
|
||||
intervalMs: 5 * 60 * 1000,
|
||||
scopes,
|
||||
instructions: 'Use Slack messages to update durable knowledge about projects, people, decisions, blockers, owners, deadlines, and status changes.',
|
||||
filters: {
|
||||
limit: 100,
|
||||
maxBodyChars: 4000,
|
||||
recentBackfillSeconds: 6 * 60 * 60,
|
||||
},
|
||||
})
|
||||
toast.success('Slack knowledge source saved')
|
||||
await refreshKnowledgeSources()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack knowledge source:', error)
|
||||
toast.error('Failed to save Slack knowledge source')
|
||||
} finally {
|
||||
setSlackKnowledgeSaving(false)
|
||||
}
|
||||
}, [parseSlackKnowledgeScopes, refreshKnowledgeSources, slackKnowledgeEnabled])
|
||||
|
||||
// Gmail (Composio)
|
||||
const refreshGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -417,6 +669,8 @@ export function useConnectors(active: boolean) {
|
|||
const refreshAllStatuses = useCallback(async () => {
|
||||
refreshGranolaConfig()
|
||||
refreshSlackConfig()
|
||||
refreshKnowledgeSources()
|
||||
refreshSlackKnowledgeStatus()
|
||||
|
||||
if (useComposioForGoogle) {
|
||||
refreshGmailStatus()
|
||||
|
|
@ -461,7 +715,7 @@ export function useConnectors(active: boolean) {
|
|||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshSlackKnowledgeStatus, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
|
||||
// Refresh when active or providers change
|
||||
useEffect(() => {
|
||||
|
|
@ -545,6 +799,11 @@ export function useConnectors(active: boolean) {
|
|||
(status) => Boolean(status?.error)
|
||||
)
|
||||
|
||||
// Whether the knowledge config has unsaved edits — drives Save button visibility.
|
||||
const slackKnowledgeDirty =
|
||||
slackKnowledgeEnabled !== slackKnowledgeSavedEnabled ||
|
||||
slackKnowledgeChannels !== slackKnowledgeSavedChannels
|
||||
|
||||
return {
|
||||
// OAuth providers
|
||||
providers,
|
||||
|
|
@ -587,9 +846,27 @@ export function useConnectors(active: boolean) {
|
|||
setSlackPickerOpen,
|
||||
slackDiscovering,
|
||||
slackDiscoverError,
|
||||
slackNeedsAuth,
|
||||
slackAuthImporting,
|
||||
slackCurlOpen,
|
||||
setSlackCurlOpen,
|
||||
slackCurlValue,
|
||||
setSlackCurlValue,
|
||||
slackCurlSubmitting,
|
||||
slackSyncStatuses,
|
||||
slackKnowledgeEnabled,
|
||||
setSlackKnowledgeEnabled,
|
||||
slackKnowledgeChannels,
|
||||
setSlackKnowledgeChannels,
|
||||
slackKnowledgeSaving,
|
||||
slackKnowledgeDirty,
|
||||
handleSlackEnable,
|
||||
handleSlackImportDesktop,
|
||||
handleSlackQuitAndImport,
|
||||
handleSlackParseCurl,
|
||||
handleSlackSaveWorkspaces,
|
||||
handleSlackDisable,
|
||||
handleSlackKnowledgeSave,
|
||||
|
||||
// Gmail (Composio)
|
||||
useComposioForGoogle,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||
|
|
@ -21,8 +23,37 @@ const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.
|
|||
// RMS threshold: system audio above this = "active" (speakers playing)
|
||||
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
|
||||
|
||||
// Auto-stop after 2 minutes of silence (no transcript from Deepgram)
|
||||
const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000;
|
||||
// RMS threshold for "someone is talking" on either channel. Drives silence
|
||||
// detection — kept a touch above the gate threshold so faint room noise on the
|
||||
// mic doesn't read as speech and keep a finished recording alive.
|
||||
const SPEECH_RMS_THRESHOLD = 0.01;
|
||||
|
||||
// Silence handling. "Silence" = no audio above SPEECH_RMS_THRESHOLD on EITHER
|
||||
// the mic or the system-audio channel (i.e. nobody — local or remote — talking).
|
||||
// - After SILENCE_NUDGE_MS we ask the user (toast) whether to stop.
|
||||
// - After SILENCE_BACKSTOP_MS we stop unconditionally.
|
||||
// - Once past the linked calendar event's end time we use the shorter
|
||||
// POST_CALENDAR_END_SILENCE_MS, since a lull after the scheduled end is a
|
||||
// strong signal the meeting is actually over.
|
||||
const SILENCE_NUDGE_MS = 2 * 60 * 1000;
|
||||
const SILENCE_BACKSTOP_MS = 5 * 60 * 1000;
|
||||
const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000;
|
||||
// How often the silence checker runs.
|
||||
const SILENCE_CHECK_INTERVAL_MS = 5 * 1000;
|
||||
|
||||
// On macOS (ScreenCaptureKit) the system-audio track never fires "ended"/"mute"
|
||||
// when the meeting ends, and its readyState stays "live" — only track.muted flips
|
||||
// to true. But muted is ambiguous: it also goes true whenever no system audio is
|
||||
// playing (a quiet but live meeting), so muted alone can't safely trigger a stop.
|
||||
// See the poll in start() for how the muted signal is gated on the scheduled
|
||||
// calendar end so a quiet stretch never cuts a live meeting short.
|
||||
const TRACK_POLL_INTERVAL_MS = 3 * 1000;
|
||||
const MUTE_POLLS_TO_STOP = 3;
|
||||
|
||||
// The ScreenCaptureKit quirk above is macOS-only; on Windows the track's "ended"
|
||||
// event fires normally (handled by the listener in start()), so the poll below is
|
||||
// gated to macOS.
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headphone detection
|
||||
|
|
@ -119,7 +150,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
|
||||
const notePathRef = useRef<string>('');
|
||||
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Silence detection: timestamp of the last speech-level audio on either
|
||||
// channel, plus the interval that checks it. calendarEndMsRef holds the
|
||||
// linked event's end time (null if none).
|
||||
const lastAudioActivityRef = useRef<number>(0);
|
||||
const silenceCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const calendarEndMsRef = useRef<number | null>(null);
|
||||
const nudgeToastIdRef = useRef<string | number | null>(null);
|
||||
// On macOS (ScreenCaptureKit) the system-audio track doesn't reliably fire
|
||||
// "ended"/"mute" when the meeting ends, so we poll its readyState/muted
|
||||
// state instead.
|
||||
const trackPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const onAutoStopRef = useRef(onAutoStop);
|
||||
onAutoStopRef.current = onAutoStop;
|
||||
const dateRef = useRef<string>('');
|
||||
|
|
@ -156,15 +197,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
}, 1000);
|
||||
}, [writeTranscriptToFile]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writeTimerRef.current) {
|
||||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
}
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = null;
|
||||
}
|
||||
const stopInputCapture = useCallback(() => {
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
|
|
@ -181,12 +214,32 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
systemStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
systemStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writeTimerRef.current) {
|
||||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
}
|
||||
if (silenceCheckRef.current) {
|
||||
clearInterval(silenceCheckRef.current);
|
||||
silenceCheckRef.current = null;
|
||||
}
|
||||
if (nudgeToastIdRef.current !== null) {
|
||||
toast.dismiss(nudgeToastIdRef.current);
|
||||
nudgeToastIdRef.current = null;
|
||||
}
|
||||
if (trackPollingRef.current) {
|
||||
clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = null;
|
||||
}
|
||||
stopInputCapture();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [stopInputCapture]);
|
||||
|
||||
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||
if (state !== 'idle') return null;
|
||||
|
|
@ -279,13 +332,6 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const transcript = data.channel.alternatives[0].transcript;
|
||||
if (!transcript) return;
|
||||
|
||||
// Reset silence auto-stop timer on any transcript
|
||||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = setTimeout(() => {
|
||||
console.log('[meeting] 2 minutes of silence — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}, SILENCE_AUTO_STOP_MS);
|
||||
|
||||
const channelIndex = data.channel_index?.[0] ?? 0;
|
||||
const isMic = channelIndex === 0;
|
||||
|
||||
|
|
@ -325,6 +371,56 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const systemStream = systemResult.value;
|
||||
systemStreamRef.current = systemStream;
|
||||
|
||||
// If the shared source goes away (user closes the call window / clicks
|
||||
// "Stop sharing"), the track fires "ended" — treat that as the meeting
|
||||
// ending and stop. Our own cleanup() calls track.stop(), which does NOT
|
||||
// fire "ended", so this won't double-trigger on a manual stop.
|
||||
systemStream.getAudioTracks().forEach(track => {
|
||||
track.addEventListener('ended', () => {
|
||||
console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
});
|
||||
});
|
||||
|
||||
// On macOS the system-audio track's "ended"/"mute" events don't fire when
|
||||
// the meeting ends, so poll its state instead. (On Windows the "ended"
|
||||
// listener above already covers this, so the poll is macOS-only.)
|
||||
//
|
||||
// - readyState === 'ended' is unambiguous (the source is gone) → stop now.
|
||||
// It never actually fires on macOS (readyState stays 'live'); it's just
|
||||
// a safety net should polling ever observe the track ending.
|
||||
// - muted is ambiguous on macOS: it flips true both when the meeting ends
|
||||
// AND when nothing is playing system audio (a quiet but live meeting).
|
||||
// So we only treat sustained mute as "meeting over" once we're past the
|
||||
// linked event's scheduled end — a dead audio track after the meeting
|
||||
// was due to finish is a strong signal. With no calendar event, or
|
||||
// before the scheduled end, we DON'T hard-stop on mute; the silence
|
||||
// checker's nudge + backstop handles it, so a quiet stretch can never
|
||||
// silently cut a live meeting short.
|
||||
const pollTrack = systemStream.getAudioTracks()[0];
|
||||
if (isMac && pollTrack) {
|
||||
let mutedPolls = 0;
|
||||
if (trackPollingRef.current) clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = setInterval(() => {
|
||||
if (pollTrack.readyState === 'ended') {
|
||||
console.log('[meeting] system-audio track ended (poll) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (pollTrack.muted) {
|
||||
mutedPolls++;
|
||||
const endMs = calendarEndMsRef.current;
|
||||
const pastCalendarEnd = endMs != null && Date.now() > endMs;
|
||||
if (pastCalendarEnd && mutedPolls >= MUTE_POLLS_TO_STOP) {
|
||||
console.log('[meeting] system-audio track muted past scheduled end (poll) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}
|
||||
} else {
|
||||
mutedPolls = 0;
|
||||
}
|
||||
}, TRACK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ----- Audio pipeline -----
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
|
|
@ -345,24 +441,33 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const micRaw = e.inputBuffer.getChannelData(0);
|
||||
const sysRaw = e.inputBuffer.getChannelData(1);
|
||||
|
||||
// RMS of each channel, computed once per frame and reused for
|
||||
// silence detection and gating the mic in speaker mode.
|
||||
let micSum = 0;
|
||||
for (let i = 0; i < micRaw.length; i++) micSum += micRaw[i] * micRaw[i];
|
||||
const micRms = Math.sqrt(micSum / micRaw.length);
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
// Reset the silence clock whenever EITHER channel has speech-level
|
||||
// audio. Uses the raw mic (pre-gating) so the user's own voice counts
|
||||
// even in speaker mode where the outgoing mic gets muted.
|
||||
if (micRms > SPEECH_RMS_THRESHOLD || sysRms > SPEECH_RMS_THRESHOLD) {
|
||||
lastAudioActivityRef.current = Date.now();
|
||||
}
|
||||
|
||||
// Mode 1 (headphones): pass both streams through unmodified
|
||||
// Mode 2 (speakers): gate/mute mic when system audio is active
|
||||
let micOut: Float32Array;
|
||||
if (usingHeadphones) {
|
||||
micOut = micRaw;
|
||||
} else if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// Compute system audio RMS to detect activity
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
|
||||
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
|
||||
|
|
@ -391,6 +496,12 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
|
||||
notePathRef.current = notePath;
|
||||
calendarEventRef.current = calendarEvent;
|
||||
|
||||
// Parse the linked event's end time (timed events only) so the silence
|
||||
// window can shorten once the meeting is past its scheduled end.
|
||||
const calEndMs = calendarEvent?.end?.dateTime ? Date.parse(calendarEvent.end.dateTime) : NaN;
|
||||
calendarEndMsRef.current = Number.isFinite(calEndMs) ? calEndMs : null;
|
||||
|
||||
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
|
|
@ -398,6 +509,45 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
opts: { encoding: 'utf8', mkdirp: true },
|
||||
});
|
||||
|
||||
// Arm silence detection. Initialise the activity clock to "now" so the
|
||||
// checker is live from the very start of recording — a session that
|
||||
// never captures any audio still auto-stops at the backstop instead of
|
||||
// running forever.
|
||||
lastAudioActivityRef.current = Date.now();
|
||||
if (silenceCheckRef.current) clearInterval(silenceCheckRef.current);
|
||||
silenceCheckRef.current = setInterval(() => {
|
||||
const silentMs = Date.now() - lastAudioActivityRef.current;
|
||||
const endMs = calendarEndMsRef.current;
|
||||
const pastCalendarEnd = endMs != null && Date.now() > endMs;
|
||||
const hardStopMs = pastCalendarEnd ? POST_CALENDAR_END_SILENCE_MS : SILENCE_BACKSTOP_MS;
|
||||
|
||||
if (silentMs >= hardStopMs) {
|
||||
console.log(`[meeting] ${Math.round(silentMs / 1000)}s of silence${pastCalendarEnd ? ' (past scheduled end)' : ''} — auto-stopping`);
|
||||
onAutoStopRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (silentMs >= SILENCE_NUDGE_MS) {
|
||||
// Ask once; the toast persists until dismissed or acted on. Past
|
||||
// the scheduled end we skip straight to the hard stop above, so
|
||||
// the nudge only ever shows for an in-progress meeting.
|
||||
if (nudgeToastIdRef.current === null) {
|
||||
nudgeToastIdRef.current = toast('Still in a meeting?', {
|
||||
description: "It's been quiet for a couple of minutes.",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Stop recording',
|
||||
onClick: () => { onAutoStopRef.current?.(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (nudgeToastIdRef.current !== null) {
|
||||
// Audio resumed before the backstop — retract the nudge.
|
||||
toast.dismiss(nudgeToastIdRef.current);
|
||||
nudgeToastIdRef.current = null;
|
||||
}
|
||||
}, SILENCE_CHECK_INTERVAL_MS);
|
||||
|
||||
setState('recording');
|
||||
return notePath;
|
||||
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
|
||||
|
|
@ -406,12 +556,14 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
if (state !== 'recording') return;
|
||||
setState('stopping');
|
||||
|
||||
stopInputCapture();
|
||||
await finalizeDeepgramStream(wsRef.current, 2200);
|
||||
cleanup();
|
||||
interimRef.current = new Map();
|
||||
await writeTranscriptToFile();
|
||||
interimRef.current = new Map();
|
||||
|
||||
setState('idle');
|
||||
}, [state, cleanup, writeTranscriptToFile]);
|
||||
}, [state, cleanup, stopInputCapture, writeTranscriptToFile]);
|
||||
|
||||
return { state, start, stop };
|
||||
}
|
||||
|
|
|
|||
123
apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx
Normal file
123
apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
import type { SessionsClient } from '@/lib/session-chat/client'
|
||||
import type { SessionFeedListener } from '@/lib/session-chat/feed'
|
||||
import {
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
requested,
|
||||
sessionState,
|
||||
turnCompleted,
|
||||
user,
|
||||
} from '@/lib/session-chat/test-fixtures'
|
||||
import { isChatMessage } from '@/lib/chat-conversation'
|
||||
import { useSessionChat } from './useSessionChat'
|
||||
|
||||
const S1 = 'sess-1'
|
||||
|
||||
function makeDeps() {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = []
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
let unsubscribed = 0
|
||||
const sessions = new Map([[S1, sessionState(S1, ['turn-1'])]])
|
||||
const turns = new Map([['turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1')]])
|
||||
const client: SessionsClient = {
|
||||
create: async () => ({ sessionId: 'x' }),
|
||||
list: async () => ({ sessions: [] }),
|
||||
get: async (sessionId) => {
|
||||
const state = sessions.get(sessionId)
|
||||
if (!state) throw new Error('session not found')
|
||||
return state
|
||||
},
|
||||
getTurn: async (turnId) => ({ turnId, events: turns.get(turnId) ?? [] }),
|
||||
sendMessage: async (...args) => {
|
||||
calls.push({ method: 'sendMessage', args })
|
||||
return { turnId: 'turn-2' }
|
||||
},
|
||||
respondToPermission: async (...args) => {
|
||||
calls.push({ method: 'respondToPermission', args })
|
||||
},
|
||||
respondToAskHuman: async (...args) => {
|
||||
calls.push({ method: 'respondToAskHuman', args })
|
||||
},
|
||||
stopTurn: async (...args) => {
|
||||
calls.push({ method: 'stopTurn', args })
|
||||
},
|
||||
resumeTurn: async () => undefined,
|
||||
setTitle: async () => undefined,
|
||||
delete: async () => undefined,
|
||||
}
|
||||
return {
|
||||
deps: {
|
||||
client,
|
||||
subscribeFeed: (listener: SessionFeedListener) => {
|
||||
emit = listener
|
||||
return () => {
|
||||
unsubscribed += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
calls,
|
||||
emit: (event: SessionBusEvent) => emit(event),
|
||||
getUnsubscribed: () => unsubscribed,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSessionChat', () => {
|
||||
it('seeds from the session, follows live events, and routes actions', async () => {
|
||||
const { deps, calls, emit } = makeDeps()
|
||||
const { result } = renderHook(() => useSessionChat(S1, deps), { wrapper: StrictMode })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.latestTurnId).toBe('turn-1')
|
||||
})
|
||||
expect(
|
||||
result.current.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['q1', 'a1'])
|
||||
|
||||
// A new turn streams in over the feed.
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
|
||||
emit({
|
||||
kind: 'turn-event',
|
||||
sessionId: S1,
|
||||
turnId: 'turn-2',
|
||||
event: { type: 'text_delta', turnId: 'turn-2', modelCallIndex: 0, delta: 'a2…' },
|
||||
})
|
||||
})
|
||||
expect(result.current.latestTurnId).toBe('turn-2')
|
||||
expect(result.current.chatState?.currentAssistantMessage).toBe('a2…')
|
||||
expect(result.current.chatState?.isProcessing).toBe(true)
|
||||
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: completed('turn-2', 0, assistantText('a2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: turnCompleted('turn-2', 'a2') })
|
||||
})
|
||||
expect(result.current.chatState?.isProcessing).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.respondToPermission('tc1', 'deny')
|
||||
await result.current.stop()
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{ method: 'respondToPermission', args: ['turn-2', 'tc1', 'deny', undefined] },
|
||||
{ method: 'stopTurn', args: ['turn-2'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('unsubscribes from the feed on unmount (StrictMode double-mounts included)', async () => {
|
||||
const { deps, getUnsubscribed } = makeDeps()
|
||||
const { unmount } = renderHook(() => useSessionChat(S1, deps), {
|
||||
wrapper: StrictMode,
|
||||
})
|
||||
unmount()
|
||||
// StrictMode's simulated cleanup plus the real unmount: every subscribe
|
||||
// was matched by an unsubscribe.
|
||||
expect(getUnsubscribed()).toBe(2)
|
||||
})
|
||||
})
|
||||
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