diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a904b3..84ff8c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, v2-pipeline-agents] pull_request: jobs: @@ -23,3 +23,39 @@ jobs: - name: Typecheck run: pnpm typecheck + + - name: Test + run: pnpm test + + # Windows parity is code-level (no hardware QA): prove the repo installs, + # typechecks, tests, and produces an unsigned Windows build on windows-latest. + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Pack web + agent + run: | + pnpm --filter @openlive/desktop pack:web + pnpm --filter @openlive/desktop pack:agent + + # --publish never: this is a build-only smoke test. Without it, + # electron-builder's publish auto-detection demands a GH_TOKEN in + # pull_request runs and fails the job after a successful build. + - name: Build Windows installer (unsigned) + run: pnpm --filter @openlive/desktop exec electron-builder --win --publish never diff --git a/.gitignore b/.gitignore index 92d7639..eabac03 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ apps/desktop/dist/ # demo videos are hosted as GitHub assets, not committed (100MB limit + no inline playback) *.mp4 + +# vendored voice runtime assets (copied from node_modules by copy-voice-assets.mjs) +apps/web/public/vad/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e2970..2f3d6e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,111 @@ All notable changes to OpenLive are recorded here. The newest version is on top. Releases before 0.1.9 predate this file — see the [GitHub releases](https://github.com/katipally/openlive/releases) for those. +## [0.2.0] - 2026-07-16 + +### Fixed +- **"The web service keeps crashing" on launch (#6).** A crashed or force-killed + run could leave the app's server processes alive (Windows especially), still + holding OpenLive's ports — every later launch then died in an EADDRINUSE + respawn loop that relaunching never fixed. The app now clears its own stale + server processes from those ports at startup (other apps' processes are left + alone), and the crash dialog says what to check if it ever still happens. +- **The workspace you picked is now the workspace the agent gets.** A bind race + on new conversations could silently strand the session with no agent and no + folder while the top bar showed both (the server's boot-time bind restore could + supersede and reverse the client's bind). The server now yields to the client's + bind, always receives the folder explicitly (empty means clear, not "keep + stale"), and echoes back what it actually bound so the UI can't drift — with a + one-shot self-heal re-bind if they ever disagree. +- **No more silent brain swap.** If a coding agent is selected but can't run + (no folder, failed start), OpenLive says exactly that instead of quietly + answering with the built-in assistant as if it were the agent. +- **Speaking to answer a permission no longer cancels it.** Starting to talk + while an agent asked for permission counted as barge-in: the ask vanished + mid-answer and your words became a new turn. Speech during a pending ask is + now the answer ("yes"/"no", matched against the agent's real option ids). +- **Model/mode pickers stop blinking out.** Resumed sessions kept their model + and mode lists (updates during session replay were dropped), the server + re-sends them on a same-bind reconnect, and the in-call top bar falls back to + the per-agent cache like the lobby always did. +- **Pre-call verification.** The lobby now checks the project folder actually + exists on disk, the built-in brain's provider has an API key, and a microphone + is present — gaps surface as chips (with a jump to the right Settings tab) + before Start instead of failing mid-call. A missing folder also gets a clear + error instead of a baffling "spawn npx ENOENT". +- **Cross-process data race.** Settings and conversations are written by both the + web and agent processes; every read-modify-write now runs under a file lock, so + a concurrent save can no longer silently drop the other side's update. +- **Agent plans and usage now actually show.** The server has always emitted the + agent's working plan (ACP plan updates) and context/cost usage — the UI dropped + both. Plans render as a live checklist above the transcript; a context/cost chip + sits in the top bar. +- **Permission asks no longer time out silently.** An unanswered agent permission + auto-denies after 2 minutes — the prompt now shows a visible countdown and the + voice speaks a reminder 30 seconds before the deadline. +- **Hermes session history.** Discovery was querying columns that don't exist in + hermes' database; rewritten against the real hermes-agent 0.18.2 schema. +- **History with huge session logs.** Reading titles from Codex rollout logs + (hundreds of MB) no longer loads whole files into memory. +- **Workspace file confinement.** The built-in assistant's file tools now refuse + symlinks that point outside the workspace, not just `../` escapes. +- Crash screen follows the OS theme and uses the brand accent. + +### Added +- **Clone Voice — clone your own voice.** A dedicated Settings tab: pick a script (or + just talk), record 5–30 seconds with a live level meter, listen back before + saving, fix the auto-transcript, and your assistant speaks as you — zero-shot + cloning (ZipVoice, Apache-2.0, via sherpa-onnx) running locally in the agent + service at ~4x realtime on CPU. Optional ~208 MB download, removable anytime. + Profiles preview with any text you type, rename, export/import between + machines, play back their original recording, and delete; automatic Kokoro + fallback; consent required — clone only your own voice or one you have + permission for. The Pipeline TTS stage just picks among your cloned voices. +- **Persona.** Settings → General gains "Your assistant's style": your own words + on how it should behave and speak, applied to the built-in assistant AND every + coding agent via its session preamble. +- **Spoken progress narration (opt-in).** While a coding agent works in silence, + OpenLive voices its plan steps ("Step 2 of 4 — …"), throttled and barge-in aware. +- **Notifications + menu bar.** A tray icon (Open / Mini mode / Quit) and OS + notifications when a turn finishes or an agent asks permission while you're in + another app — clicking brings OpenLive forward. +- **Markdown transcript.** Agent replies render as real markdown — code blocks + with copy buttons, lists, tables — plus per-message copy and a one-tap export + of the whole conversation to a Markdown file. +- **In-call keyboard shortcuts.** M mute, C camera, S screen share, T activity + panel, H history, Cmd/Ctrl-E end call — press `?` for the cheat sheet. +- **Lobby readiness check.** Picking an agent that isn't installed or signed in + shows a one-tap jump to Settings → Agents instead of failing the call. +- **A real player for voice previews.** Everything Clone Voice plays back — the + recorded take, a synthesized preview, the original recording — now goes through + a compact seekable player (play/pause, drag to seek, elapsed/total time) instead + of fire-and-forget playback. Only one plays at a time. +- **Agent sign-in that can't strand you.** Sign-in and setup flows open in your + terminal; the row now polls while you finish there and flips to Ready by itself. + If the terminal can't open (macOS Automation permission), the panel explains the + fix and a Copy command button gives you the manual path. Hermes gets an honest + "Setup incomplete" state (its wizard was started but no provider picked), a + "Finish setup" button, and an Uninstall that removes `~/.hermes` after a warning. + +### Changed +- **Light mode rebuilt.** A stepped warm-paper ladder (no pure white): cards, + panels, and popovers now separate cleanly instead of fusing into one white + field, and borders are actually visible. +- **README and docs rewritten** around what OpenLive is: the open voice and + vision layer for AI agents — bring your own model, with coding agents over ACP + as the flagship integration. Includes an honest note that the pipeline is + cascaded, not full-duplex speech-to-speech. +- **VAD assets are served from the app itself** (vendored at build time) instead + of a CDN — the voice loop no longer touches jsdelivr at runtime. +- **Agents settings shows each CLI's version** and gains an **Update** button; + a failed npm install from a root-owned prefix now gets actionable guidance + instead of a raw error dump. +- Slash-command metadata (never surfaced in the UI) removed from the wire protocol. +- **Settings reorganized.** The Voices tab is now **Clone Voice**. Speaking speed + and "Narrate agent progress" moved from Pipeline → Text-to-speech to General + under a new **Voice & speech** group, next to voice input — everyday preferences + in General, engine choices in Pipeline. Same settings underneath; nothing resets. + ## [0.1.9] - 2026-07-11 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46bf294..6036edb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,16 +30,22 @@ pnpm desktop:build:win # build the Windows installer (run on Windows) ## Where things live ``` -apps/desktop Electron shell: local servers, permissions, window, auto-update -apps/web Next.js UI + the on-device voice engine in src/lib/live -services/agent the /live WebSocket and the model tools +apps/desktop Electron shell: local servers, permissions, window, mini mode, + tray + notifications, auto-update +apps/web Next.js UI + the on-device voice engine in src/lib/live + /api + routes (agent install/auth, history discovery, settings) +services/agent the /live WebSocket, the ACP coding-agent driver (agents/*), + local voice cloning (voice/*), and the built-in model tools packages/harness model adapters (Anthropic / OpenAI Responses / OpenAI Chat), model listing -packages/shared the wire protocol and shared types +packages/shared the agent registry (single source of agent identity), wire + protocol, shared types packages/db JSON-file store for keys, settings, conversations ``` -The voice loop (VAD, STT, end-of-turn, TTS, barge-in) is in -`apps/web/src/lib/live`. The model turn goes out from `services/agent`. +The voice loop (VAD, STT, end-of-turn, TTS — Kokoro, Supertonic, or a cloned +voice — and barge-in) is in `apps/web/src/lib/live`. The model turn goes out from +`services/agent`, which either streams a provider reply or drives a coding agent +(Claude Code, Codex, Cursor, OpenCode, Hermes) over ACP as a child process. ## Sending a change diff --git a/README.md b/README.md index 3651dad..3ec32fe 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ ### The open voice and vision layer for AI agents. -Bring your own model. Get real-time speech and sight, running on your own machine. -An open alternative to ElevenLabs, Gemini Live, and OpenAI Realtime. +Your AI can think. OpenLive gives it ears, a mouth, and eyes. +Bring your own model, or talk to the coding agents you already use, with the whole +voice loop running on your own machine. An open alternative to ElevenLabs Agents, +Gemini Live, and OpenAI Realtime. [![Release](https://img.shields.io/github/v/release/katipally/openlive?color=2f6fed)](https://github.com/katipally/openlive/releases/latest) [![CI](https://github.com/katipally/openlive/actions/workflows/ci.yml/badge.svg)](https://github.com/katipally/openlive/actions/workflows/ci.yml) @@ -28,42 +30,78 @@ https://github.com/user-attachments/assets/6ebe0e47-44cb-4d4f-bc33-7f15651e6342 ## What this is -Wiring an AI agent to a real conversation is harder than it looks. You need voice -activity detection, a way to know when the person actually stopped talking, -streaming speech-to-text, a model turn, streaming text-to-speech, and barge-in so -the user can interrupt. Then you want it to see, so add camera and screen frames on -top. Most people give up and rent a closed platform that meters every minute and -sends the audio to someone else's cloud. - -OpenLive is that plumbing, built and open. It connects any chat model to real-time -voice and vision, and the whole voice loop runs on-device. The desktop app is the -reference build: download it, paste a key for the model you want, and talk. - -You bring the brain — Anthropic, OpenAI, Google, xAI, DeepSeek, Groq, Ollama, and a -dozen more. OpenLive gives it ears, a mouth, and eyes. +Wiring an AI into a real conversation is harder than it looks: voice activity +detection, knowing when someone actually stopped talking, streaming speech-to-text, +the model turn, streaming text-to-speech, and barge-in so you can interrupt. Then +camera and screen on top. Hosted platforms rent you that pipeline by the minute and +run it on their cloud. + +OpenLive is that pipeline, open and local. The listening, the speaking, and the +watching all run on-device (WebGPU). You bring the brain, and any brain works: + +- **A model you have a key for.** Anthropic, OpenAI, Google, xAI, DeepSeek, Groq, + Ollama (fully local), and a dozen more. No per-minute audio fees; you pay only + the model costs you'd pay anyway. +- **The coding agent you already use.** Claude Code, Codex, Cursor, OpenCode, or + Hermes, driven locally over the + [Agent Client Protocol](https://agentclientprotocol.com) (JSON-RPC over stdio), + under your own login. Talk to your agent, watch it work, answer its permission + asks by voice. + +Whichever brain you pick, OpenLive is the same thing it has always been: the ears, +mouth, and eyes around it. Nothing you say leaves the machine. The only thing that +goes out is the final transcript (plus camera or screen frames if you turn them on), +to whatever brain you picked. + +An honest note on architecture: OpenLive is a cascaded pipeline (speech to text to +model to speech), not a full-duplex speech-to-speech model like GPT-Live. That's a +real trade. A speech-native model can overlap talk and listen in ways a cascade +can't, but the cascade is exactly what makes "any brain, all local, no audio fees" +possible. ## Features -- **On-device voice loop.** Voice activity detection (Silero), speech-to-text - (Whisper), end-of-turn detection (Smart-Turn), and text-to-speech (Kokoro) all - run in the app on WebGPU. Nothing you say leaves the machine. -- **It can see.** Turn on your camera or share your screen and the model watches it - live, like a video call. The `look` tool grabs a crisp hi-res frame when it needs - to read a label or a line of code. Running a text-only model? Point OpenLive at a - separate vision model and it does the seeing while your main model does the talking. -- **Bring your own model.** Over a dozen providers out of the box — Anthropic, - OpenAI, Google Gemini, xAI Grok, DeepSeek, Mistral, MiniMax, the fast-inference - hosts (Groq, Cerebras, Together, Fireworks), OpenRouter, Perplexity, and Ollama - (local or cloud). Models are fetched live from each provider with vision / reasoning - / context / price surfaced in the picker, and reasoning effort is a dial from Auto - to Max. It's a layer, not a walled app — fork it to wire up your own agent backend - or a self-hosted endpoint. -- **Agent tools.** Web search, fetch a URL, remember a fact across calls, and a live - checklist — plus, in the desktop app, read/write your clipboard and open a URL. +The core, the ears / mouth / eyes: + +- **On-device voice loop.** Silero VAD, Whisper STT, Smart-Turn end-of-turn, and + your pick of two TTS engines: Kokoro (28 voices, light) or Supertonic (10 voices, + 44.1 kHz). All of it runs in the app on WebGPU. +- **Speak as yourself.** Settings → Clone Voice records 5 to 30 seconds of you + (with a seekable listen-back before anything is saved) and your assistant speaks + in your voice from then on. Zero-shot cloning (ZipVoice, Apache-2.0) running + locally, an optional ~208 MB install, deletable anytime. Profiles preview with any + text, rename, and export/import between machines. Clone only your own voice or + one you have clear permission to use; impersonation is on you, not the tool. +- **It can see.** Camera or screen frames ride each turn, and the `look` tool grabs + a crisp hi-res frame on demand. A text-only model can borrow a separate vision + model's eyes. - **Barge-in.** Interrupt any time and it stops mid-word, like a real conversation. +- **Your assistant, your way.** Custom instructions in Settings → General apply to + every brain, built-in or agent. Speaking speed and spoken progress narration live + there too. + +The integrations that serve it: + +- **Voice-drive your coding agent.** Pick Claude Code / Codex / Cursor / OpenCode / + Hermes per conversation, pick its project folder, and talk. Model, mode + (ask / accept edits / bypass), and the agent's other options switch mid-call, all + reported by the agent itself over ACP. +- **Sessions are the agent's own.** A call with Claude Code lands in + `~/.claude/projects/…` where `claude --resume` finds it, and the agent's existing + CLI sessions show up in OpenLive's History. Resume from either side. +- **Permission relay.** When the agent wants to run a command or edit files, + OpenLive speaks the question; answer by voice ("yes" / "no") or tap. +- **Narrated progress.** Optional: while the agent works in silence, OpenLive speaks + its plan steps out loud ("Step 2 of 4 — refactor the store"). +- **Live plans and costs.** The agent's working plan renders as a checklist while it + works, and a context/cost chip tracks the session. +- **Manage agents in Settings.** Install, sign in, update, and uninstall each + agent's CLI from the app. Status updates itself while you finish a sign-in in the + terminal, and if the terminal can't open you get the exact command to run instead. - **Floating mini mode.** Shrink to an always-on-top pill that keeps listening while - you work; camera and screen previews stack right above it. -- **Resume conversations.** Sessions are saved locally — pick one and carry on. + you work, with a menu-bar tray and notifications to close the loop. +- **A transcript you can use.** Agent replies render as markdown with copy buttons + on code blocks, and the whole conversation exports to a Markdown file. - **Private by design.** Audio never uploads. API keys are encrypted at rest (AES-256-GCM) and only the last four digits are ever shown. @@ -72,39 +110,45 @@ dozen more. OpenLive gives it ears, a mouth, and eyes. | Home | In a live call | |---|---| | ![Home](assets/home.png) | ![In a live call](assets/hero.png) | -| **Pre-call setup** | **Settings — bring your own model** | +| **Pre-call setup** | **Settings — agents** | | ![Pre-call setup](assets/lobby.png) | ![Settings](assets/settings.png) | +| **Clone your voice** | **Mini mode** | +| ![Clone Voice](assets/clone-voice.png) | ![Mini mode](assets/mini-mode.png) | ## Why on-device voice matters The listening and speaking never leave your computer. The only thing that goes out -is the text turn to the model provider you picked — the same call you would make -from any app. No audio uploads, no per-minute meter, no lock-in. +is the text turn to the brain you picked, the same call you would make from any app. +No audio uploads, no per-minute meter, no lock-in. That also skips the separate speech-to-text, text-to-speech, and real-time-audio fees hosted platforms charge on top. You still pay your normal model and vision API -costs — nothing more. +costs, nothing more. With a coding agent as the brain there's nothing extra to pay +at all; it runs under the login you already have. ## How it works ``` -mic ─▶ VAD ─▶ streaming STT ─▶ end-of-turn ─▶ your model ─▶ streaming TTS ─▶ speaker - (Whisper) (Smart-Turn) (BYO key) (Kokoro) - ▲ - camera / screen frames ┘ (vision) +mic ─▶ VAD ─▶ streaming STT ─▶ end-of-turn ─▶ your AI ──────────▶ streaming TTS ─▶ speaker + (Silero) (Whisper) (Smart-Turn) (BYO model, or a (Kokoro / Supertonic / + ▲ coding agent over your cloned voice) + camera / screen ────┘ ACP on local stdio) + frames (vision) ``` -Everything above the model runs locally in the renderer. The model turn goes out -over a warm WebSocket to a small local agent, which streams the reply back so the -app can start speaking sentence by sentence. Interrupt any time and it stops mid-word. +Everything outside "your AI" runs locally in the renderer. The turn goes over a warm +local WebSocket to a small agent server, which either streams a provider reply or +drives your coding agent's ACP adapter as a child process. The app starts speaking +sentence by sentence while the reply is still being written. ## Get started **Just use it:** grab the installer from the -[latest release](https://github.com/katipally/openlive/releases/latest), open the -app, go to Settings, pick a provider, paste your API key, and start a call. Keys are -encrypted on disk and the voice models download the first time you talk (about -200 MB, cached after that). +[latest release](https://github.com/katipally/openlive/releases/latest), open the app, +paste a model key (or pick the coding agent you already use — install/sign in from +Settings → Agents if needed), and start a call. The voice models download from +Hugging Face the first time you talk — roughly 200 MB with Kokoro, more with +Supertonic or a bigger Whisper — and are cached after that. **Build it from source:** @@ -114,34 +158,26 @@ pnpm desktop:dev # runs the web + agent servers and opens the app window ``` You can also run it in a browser during development with `pnpm dev`, then open -`localhost:3000`. +`localhost:3000`. Run the tests with `pnpm test`. ## Repo layout ``` -apps/desktop Electron shell: spawns the local servers, media perms, window, auto-update -apps/web Next.js UI + the on-device voice engine (src/lib/live/*) + /api settings -services/agent Hono + ws: the /live WebSocket, the delegate → worker tool loop - (web search via Exa, fetch_url), remember, update_todos, look, clipboard +apps/desktop Electron shell: spawns the local servers, media perms, window, + mini mode, tray + notifications +apps/web Next.js UI + the on-device voice engine (src/lib/live/*) + /api routes + (agents install/auth, history discovery, settings) +services/agent Hono + ws: the /live WebSocket, the ACP agent driver (acp-agent.ts, + supervisor.ts), voice cloning (voice/*), the built-in provider turn loop +packages/shared the agent registry (single source of agent identity), wire protocol, + shared types packages/harness provider-neutral model adapters, live model listing, cost/effort -packages/shared wire protocol + shared types packages/db JSON-file store: encrypted keys, settings, conversations ``` -For how the pieces fit together — the thin-server design, the voice loop, and the +For how the pieces fit together — the ACP driver, the voice loop, resume, and the delegate/worker tool flow — see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). -## Other ways to run it - -`main` is the desktop app, which has the lowest latency because the servers run -locally with a warm socket. Two other branches trade some latency for reach: - -| Branch | What it is | Where it runs | -|---|---|---| -| `main` | Desktop app (Electron), WebSocket agent | your machine | -| `docker-websocket` | The same WebSocket app as one Docker image | self-host | -| `serverless-sse` | A serverless rewrite, one streaming turn per request | Vercel | - ## Contributing OpenLive is open to contributions. Start with [CONTRIBUTING.md](CONTRIBUTING.md) for @@ -151,5 +187,3 @@ labeled in the tracker. ## License [MIT](LICENSE). Use it, change it, ship it. - - diff --git a/RELEASING.md b/RELEASING.md index 86ab475..48a704f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Maintainer notes — not linked from the README on purpose. CI typechecks every push and PR. A release is one tag, no manual version bump: ```bash -git tag v0.1.8 && git push origin v0.1.8 +git tag v0.2.0 && git push origin v0.2.0 ``` The tag drives the version. CI builds the macOS (universal, signed and notarized) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cb82f08..1feacc8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,7 +22,7 @@ then runs electron-builder. Ports are the uncommon `47823` (agent) / `47824` (web) to avoid collisions on users' machines. ```bash -pnpm desktop:build:mac # → apps/desktop/release/OpenLive--arm64.dmg (+ x64) +pnpm desktop:build:mac # → apps/desktop/release/OpenLive--mac.dmg (universal) pnpm desktop:build:win # → NSIS installer (run this on Windows / CI) ``` diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index d07622a..b41ff7f 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -20,6 +20,9 @@ files: - main.cjs - preload.cjs - splash.html + # Packed INTO the asar so the menu-bar tray (main.cjs loads build/icon.png via + # __dirname) has its image in installed builds — without this the tray was blank. + - build/icon.png extraResources: - from: dist/web diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index def2b8b..453b0dc 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -2,12 +2,11 @@ // OpenLive desktop shell. Runs the web (Next) + agent (ws) servers locally and // shows the UI in a native window. Everything is on localhost — the voice models // run in the renderer (Chromium/WebGPU), the LLM call goes out from the agent. -const { app, BrowserWindow, Menu, session, shell, dialog, desktopCapturer, ipcMain, screen, clipboard } = require("electron"); -const { spawn } = require("node:child_process"); +const { app, BrowserWindow, Menu, Notification, Tray, nativeImage, session, shell, dialog, desktopCapturer, ipcMain, screen, clipboard, globalShortcut } = require("electron"); +const { spawn, execSync } = require("node:child_process"); const path = require("node:path"); const fs = require("node:fs"); const http = require("node:http"); -const crypto = require("node:crypto"); // Crash early, loud, and visible instead of dying silently. process.on("uncaughtException", (e) => { console.error("[main] uncaught:", e); }); @@ -78,7 +77,7 @@ function spawnServer(name, scriptRel, env) { const r = (restarts[name] ||= { count: 0, first: Date.now() }); if (Date.now() - r.first > 60000) { r.count = 0; r.first = Date.now(); } // reset the window if (++r.count > 5) { - dialog.showErrorBox("OpenLive stopped", `The ${name} service keeps crashing. Please relaunch the app.`); + dialog.showErrorBox("OpenLive stopped", `The ${name} service keeps crashing. Relaunch the app; if it keeps happening, check that nothing else is using ports ${AGENT_PORT} and ${WEB_PORT}, and please attach any console output to a GitHub issue.`); return; } setTimeout(() => { if (!app.isQuitting) spawnServer(name, scriptRel, env); }, 500); @@ -87,24 +86,60 @@ function spawnServer(name, scriptRel, env) { return child; } +// A crashed or force-killed run can leave the server children alive — Windows +// especially, where children aren't tied to the parent's lifetime — still holding +// 47823/47824. The next launch's servers then die with EADDRINUSE in a respawn +// loop that ends at "The web service keeps crashing" (issue #6), and relaunching +// never helps. We hold the single-instance lock, so any OTHER OpenLive process +// listening on our ports is a zombie: kill it. Foreign processes are left alone. +function freeStalePorts() { + const sh = (cmd) => { try { return execSync(cmd, { encoding: "utf8" }); } catch { return ""; } }; + const mine = path.basename(process.execPath).toLowerCase(); // zombie children ran OUR binary (ELECTRON_RUN_AS_NODE) + for (const port of [AGENT_PORT, WEB_PORT]) { + if (process.platform === "win32") { + for (const line of sh("netstat -ano -p tcp").split("\n")) { + const c = line.trim().split(/\s+/); // proto | local | foreign | state | pid + if (c.length < 5 || c[3] !== "LISTENING" || !c[1].endsWith(`:${port}`)) continue; + const pid = c[4]; + if (!pid || pid === "0" || Number(pid) === process.pid) continue; + if (sh(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`).toLowerCase().includes(mine)) { + console.error(`[main] killing stale ${mine} (pid ${pid}) holding port ${port}`); + sh(`taskkill /F /PID ${pid}`); + } + } + } else { + for (const pid of sh(`lsof -ti tcp:${port} -sTCP:LISTEN`).split("\n").filter(Boolean)) { + if (Number(pid) === process.pid) continue; + if (sh(`ps -p ${pid} -o comm=`).trim().toLowerCase().includes(mine)) { + console.error(`[main] killing stale ${mine} (pid ${pid}) holding port ${port}`); + sh(`kill -9 ${pid}`); + } + } + } + } +} + function startServers() { if (DEV) return; // dev servers come from `pnpm dev` + freeStalePorts(); const dataDir = path.join(app.getPath("userData"), "data"); - const secret = crypto.randomUUID(); - // Agent (internal): the renderer connects to it directly over ws on localhost. + // The agent binds loopback only (services/agent/src/server.ts defaults AGENT_HOST + // to 127.0.0.1), so it is never reachable off this machine. That closes the LAN + // exposure by itself; the renderer connects over localhost. spawnServer("agent", "agent/agent.mjs", { AGENT_PORT: String(AGENT_PORT), + AGENT_HOST: "127.0.0.1", OPENLIVE_DATA_DIR: dataDir, WEB_PUBLIC_URL: WEB_URL, - // No OPENLIVE_AGENT_SECRET → the agent accepts the direct localhost socket. }); // Web (Next standalone) serves the UI + the /api settings routes (JSON store). + // AGENT_PORT: the /api/voice proxy forwards to the agent on localhost. spawnServer("web", "web/server.js", { PORT: String(WEB_PORT), HOSTNAME: WEB_HOST, NODE_ENV: "production", OPENLIVE_DATA_DIR: dataDir, - OPENLIVE_AGENT_SECRET: secret, + AGENT_PORT: String(AGENT_PORT), }); } @@ -178,6 +213,9 @@ function createMainWindow() { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, + // Mini mode HIDES this window while its renderer keeps running the whole voice + // pipeline — throttled timers would wreck turn-taking (hold timers, TTS drain). + backgroundThrottling: false, // Hand the app version to the preload (app.* isn't reachable there). Released // builds show the tag version (CI stamps it); unpackaged dev builds get a // "-dev" suffix so it's obvious you're not on a release. @@ -186,10 +224,18 @@ function createMainWindow() { }); for (const ev of ["resize", "move", "close"]) mainWin.on(ev, saveWindowState); - // Open external links (docs, etc.) in the real browser, not the app window. + // Open external http(s) links (docs, etc.) in the real browser; DENY every other + // popup (file:, data:, etc.) rather than letting it open an in-app window. mainWin.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith("http")) { shell.openExternal(url); return { action: "deny" }; } - return { action: "allow" }; + if (/^https?:\/\//i.test(url)) shell.openExternal(url); + return { action: "deny" }; + }); + // Keep the main frame pinned to our own UI: an in-page navigation to anywhere + // other than the local app is blocked (http(s) is handed to the real browser). + mainWin.webContents.on("will-navigate", (e, url) => { + if (url.startsWith(WEB_URL)) return; + e.preventDefault(); + if (/^https?:\/\//i.test(url)) shell.openExternal(url); }); mainWin.loadURL(WEB_URL); @@ -201,70 +247,152 @@ function createMainWindow() { mainWin.on("closed", () => { mainWin = null; }); } -// ── minimized (floating pill) mode ─────────────────────────────────────────── -// Mini mode shrinks the SINGLE main window to a small floating pill that still runs -// the whole voice pipeline AND owns the camera/screen streams — so the renderer -// shows the previews INLINE (stacked above the pill) with no separate windows. The -// pill grows UPWARD as previews appear (its bottom edge stays put). Opaque, -// always-on-top; macOS rounds the frameless window natively. +// ── minimized (floating panel) mode ────────────────────────────────────────── +// Mini mode HIDES the main window (its renderer keeps running the voice pipeline — +// backgroundThrottling is off) and shows a separate thin PANEL window with the pill +// UI. The panel is non-activating (clicking it never steals focus from the app +// you're working in), floats above fullscreen apps, and lives on every Space. +// State flows main-renderer → main process → panel; commands flow back the same +// way (a MediaStream can't cross windows, so previews arrive as ~1 fps JPEGs). const PILL_W = 430, PILL_H = 56; -let savedBounds = null; +let panelWin = null; function miniDisplay() { - return screen.getDisplayMatching(savedBounds || (mainWin ? mainWin.getBounds() : { x: 0, y: 0, width: 0, height: 0 })); + return screen.getDisplayMatching(mainWin ? mainWin.getBounds() : { x: 0, y: 0, width: 0, height: 0 }); } function pillBottom(area) { return area.y + area.height - 72; } // clear the dock -function wireMiniIpc() { - ipcMain.on("openlive:mini", () => { - if (!mainWin) return; - const applyPill = () => { - if (!mainWin) return; - if (!savedBounds) savedBounds = mainWin.getBounds(); - const area = miniDisplay().workArea; - mainWin.setResizable(false); - mainWin.setMinimumSize(PILL_W, PILL_H); - mainWin.setBounds({ width: PILL_W, height: PILL_H, x: area.x + Math.round((area.width - PILL_W) / 2), y: pillBottom(area) - PILL_H }); - mainWin.setAlwaysOnTop(true, "floating"); - mainWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - }; - // A fullscreen (or simple-fullscreen) window ignores setBounds — leave it first, - // then shrink to the pill once the OS transition completes. - if (mainWin.isFullScreen() || mainWin.isSimpleFullScreen()) { - mainWin.once("leave-full-screen", applyPill); - mainWin.setFullScreen(false); - } else { - if (mainWin.isMaximized()) mainWin.unmaximize(); - applyPill(); - } +function createPanelWindow() { + if (panelWin && !panelWin.isDestroyed()) { panelWin.show(); return; } + const area = miniDisplay().workArea; + panelWin = new BrowserWindow({ + width: PILL_W, height: PILL_H, + x: area.x + Math.round((area.width - PILL_W) / 2), y: pillBottom(area) - PILL_H, + show: false, frame: false, resizable: false, skipTaskbar: true, + roundedCorners: true, backgroundColor: DARK_BG, + // macOS: a "panel"-type window is non-activating — clicks land on its buttons + // without pulling focus away from whatever app the user is working in. + ...(process.platform === "darwin" ? { type: "panel", focusable: false } : {}), + webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }, }); - ipcMain.on("openlive:unmini", () => { + panelWin.setAlwaysOnTop(true, "floating", 1); + panelWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, skipTransformProcessType: true }); + panelWin.loadURL(`${WEB_URL}/mini`); + panelWin.once("ready-to-show", () => { if (panelWin) panelWin.showInactive(); }); + panelWin.on("closed", () => { panelWin = null; }); +} + +// The global mini-mode talk hotkey. Configurable from Settings → General; kept in +// memory here (the renderer persists it and re-sends on each mini entry). +// globalShortcut has no keyup, so it's always a press-to-TOGGLE. +let miniHotkey = "Alt+Space"; + +// Module-scope so BOTH enterMini() and the set-mini-hotkey IPC handler can arm it. +// (It used to be declared inside wireMiniIpc(), so enterMini()'s call threw a +// swallowed ReferenceError → the "Alt+Space from anywhere" hotkey never armed.) +function registerMiniHotkey() { + try { + globalShortcut.unregisterAll(); + return globalShortcut.register(miniHotkey, () => { if (mainWin) mainWin.webContents.send("openlive:ptt-toggle"); }); + } catch { return false; } +} + +/** Enter mini mode: spawn the always-on-top panel, hide the main window, arm the + * global talk hotkey. Shared by the minimize button (IPC) and the tray menu. */ +function enterMini() { + if (!mainWin) return; + const apply = () => { if (!mainWin) return; - mainWin.setAlwaysOnTop(false); - mainWin.setVisibleOnAllWorkspaces(false); - mainWin.setResizable(true); - mainWin.setMinimumSize(940, 640); - if (savedBounds) { mainWin.setBounds(savedBounds); savedBounds = null; } + createPanelWindow(); + mainWin.hide(); + }; + // Leaving fullscreen first: hiding a fullscreen window strands an empty Space. + if (mainWin.isFullScreen() || mainWin.isSimpleFullScreen()) { + mainWin.once("leave-full-screen", apply); + mainWin.setFullScreen(false); + } else apply(); + // Global push-to-talk while the panel is up: talk to the agent from any app. + registerMiniHotkey(); +} + +/** Leave mini mode / bring the app forward. Shared by IPC, the tray menu, and + * notification clicks. */ +function restoreMainWindow() { + globalShortcut.unregisterAll(); + if (panelWin && !panelWin.isDestroyed()) panelWin.destroy(); + panelWin = null; + if (mainWin) { mainWin.show(); mainWin.focus(); } +} + +// ── menu-bar (tray) presence + notifications ───────────────────────────────── +let tray = null; + +function createTray() { + try { + const img = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png")).resize({ height: 18 }); + tray = new Tray(img); + tray.setToolTip("OpenLive"); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: "Open OpenLive", click: restoreMainWindow }, + { label: "Mini mode", click: enterMini }, + { type: "separator" }, + { label: "Quit OpenLive", role: "quit" }, + ])); + } catch (e) { console.error("[main] tray:", e); } // no tray beats no app +} + +function wireNotifyIpc() { + // Renderer asks for an OS notification ("agent finished", "permission needed"). + // Only shown when the user ISN'T looking at the app — focused-and-visible means + // they already see it. Clicking brings OpenLive forward (also out of mini mode). + ipcMain.on("openlive:notify", (_e, p) => { + const title = String(p?.title ?? "").slice(0, 80); + if (!title || !Notification.isSupported()) return; + if (mainWin && mainWin.isVisible() && mainWin.isFocused()) return; + const n = new Notification({ title, body: String(p?.body ?? "").slice(0, 180), silent: true }); + n.on("click", restoreMainWindow); + n.show(); + }); +} + +function wireMiniIpc() { + // Change the hotkey (Settings → General). Re-registers live if the panel is up; + // an invalid/taken accelerator falls back to the previous one and reports it. + ipcMain.handle("openlive:set-mini-hotkey", (_e, acc) => { + const prev = miniHotkey; + miniHotkey = String(acc || "Alt+Space"); + if (panelWin && !panelWin.isDestroyed()) { + if (!registerMiniHotkey()) { miniHotkey = prev; registerMiniHotkey(); return { ok: false, hotkey: prev }; } + } + return { ok: true, hotkey: miniHotkey }; }); - // The pill fits its content: the renderer measures the stacked previews + pill and - // asks for a height. Grow UPWARD — keep the bottom edge fixed so the pill doesn't - // walk up/down the screen as previews toggle. + ipcMain.on("openlive:mini", enterMini); + ipcMain.on("openlive:unmini", restoreMainWindow); + // The panel fits its content: its renderer measures the stacked previews + pill + // and asks for a height. Grow UPWARD — the bottom edge stays put. ipcMain.on("openlive:mini-size", (_e, h) => { - if (!mainWin || !mainWin.isAlwaysOnTop()) return; + if (!panelWin || panelWin.isDestroyed()) return; const area = miniDisplay().workArea; - // Fit the content snugly (down to ~a bare pill); don't force the full PILL_H so - // there's no empty strip above the composer. const height = Math.max(44, Math.min(area.height - 96, Math.round(h) || PILL_H)); - const b = mainWin.getBounds(); + const b = panelWin.getBounds(); if (height === b.height) return; const bottom = b.y + b.height; const y = Math.max(area.y + 8, bottom - height); - mainWin.setBounds({ x: b.x, y, width: PILL_W, height }, true); // animate the grow (mac) + panelWin.setBounds({ x: b.x, y, width: PILL_W, height }, true); // animate the grow (mac) }); + // State/command relay between the main renderer (voice pipeline) and the panel. + ipcMain.on("openlive:panel-state", (_e, s) => { if (panelWin && !panelWin.isDestroyed()) panelWin.webContents.send("openlive:panel-state", s); }); + ipcMain.on("openlive:panel-cmd", (_e, c) => { if (mainWin) mainWin.webContents.send("openlive:panel-cmd", c); }); } // ── custom window controls (frameless window → no native traffic lights) ───── function wireWindowIpc() { + // Launch-at-login (Settings → General). Invoke with a boolean to set; with + // undefined to just read the current state. + ipcMain.handle("openlive:login-item", (_e, v) => { + if (typeof v === "boolean") app.setLoginItemSettings({ openAtLogin: v }); + return app.getLoginItemSettings().openAtLogin; + }); ipcMain.on("openlive:win-close", () => { if (mainWin) mainWin.close(); }); ipcMain.on("openlive:win-min", () => { if (mainWin) mainWin.minimize(); }); ipcMain.on("openlive:win-zoom", () => { @@ -279,6 +407,11 @@ function wireBridgeIpc() { try { if (op === "clipboard_read") { const t = clipboard.readText(); return t ? `The clipboard contains: ${t}` : "The clipboard is empty."; } if (op === "clipboard_write") { clipboard.writeText(String(arg ?? "")); return "Copied it to the clipboard."; } + if (op === "pick_folder") { + const opts = { title: "Choose a project folder", properties: ["openDirectory", "createDirectory"] }; + const r = await (mainWin ? dialog.showOpenDialog(mainWin, opts) : dialog.showOpenDialog(opts)); + return r.canceled ? "" : (r.filePaths[0] ?? ""); + } if (op === "open_url") { let u = String(arg ?? "").trim(); if (!/^https?:\/\//i.test(u)) u = `https://${u}`; @@ -366,8 +499,10 @@ function checkForUpdatesNow() { async function boot() { buildMenu(); + createTray(); wirePermissions(); wireMiniIpc(); + wireNotifyIpc(); wireWindowIpc(); wireBridgeIpc(); createSplash(); @@ -387,3 +522,4 @@ app.whenReady().then(boot); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); app.on("before-quit", () => { app.isQuitting = true; for (const c of children) { try { c.kill(); } catch { /* */ } } }); +app.on("will-quit", () => globalShortcut.unregisterAll()); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7d33499..58c11f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,13 +1,13 @@ { "name": "@openlive/desktop", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "OpenLive — live voice + vision AI assistant (desktop)", "author": "OpenLive", "main": "main.cjs", "scripts": { "start": "electron .", - "start:dev": "ELECTRON_DEV=1 electron .", + "start:dev": "cross-env ELECTRON_DEV=1 electron .", "pack:web": "node scripts/pack-web.cjs", "pack:agent": "node scripts/pack-agent.cjs", "prebuild": "node scripts/pack-web.cjs && node scripts/pack-agent.cjs", @@ -21,6 +21,7 @@ "devDependencies": { "electron": "^33.2.1", "electron-builder": "^25.1.8", - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "cross-env": "^10.0.0" } } diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index 8effd16..0453841 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -5,6 +5,7 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("openlive", { + platform: process.platform, // Enter minimized mode: shrink to a small floating pill (always-on-top). mini: () => ipcRenderer.send("openlive:mini"), // Restore the normal window. @@ -19,10 +20,26 @@ contextBridge.exposeInMainWorld("openlive", { // OS bridge for agent tools. op: "clipboard_read" | "clipboard_write" | "open_url". // Resolves to a short result string the agent speaks back. bridge: (op, arg) => ipcRenderer.invoke("openlive:bridge", { op, arg }), + // OS notification — shown only when the app isn't focused (main decides). + notify: (title, body) => ipcRenderer.send("openlive:notify", { title, body }), + // Settings → General: launch-at-login (boolean sets, undefined reads) and the + // configurable global mini-mode talk hotkey. + loginItem: (v) => ipcRenderer.invoke("openlive:login-item", v), + setMiniHotkey: (acc) => ipcRenderer.invoke("openlive:set-mini-hotkey", acc), // True when running inside the desktop app. isDesktop: true, // App version, passed from main via additionalArguments (set from the release tag). version: (process.argv.find((a) => a.startsWith("--openlive-version=")) || "").split("=")[1] || "", // The native menu (⌘,) asks the UI to open Settings. onOpenSettings: (cb) => ipcRenderer.on("openlive:open-settings", () => cb()), + // Global push-to-talk toggle (mini mode's Alt+Space). Single listener: each call + // replaces the previous callback so remounts don't stack stale handlers. + onPttToggle: (cb) => { ipcRenderer.removeAllListeners("openlive:ptt-toggle"); ipcRenderer.on("openlive:ptt-toggle", () => cb()); }, + // Mini-panel bridge. The main renderer (voice pipeline) publishes state; the panel + // window renders it and sends control commands back. Single listener each, same + // replace-on-subscribe rule as above. + panelState: (s) => ipcRenderer.send("openlive:panel-state", s), + onPanelState: (cb) => { ipcRenderer.removeAllListeners("openlive:panel-state"); ipcRenderer.on("openlive:panel-state", (_e, s) => cb(s)); }, + panelCmd: (c) => ipcRenderer.send("openlive:panel-cmd", c), + onPanelCmd: (cb) => { ipcRenderer.removeAllListeners("openlive:panel-cmd"); ipcRenderer.on("openlive:panel-cmd", (_e, c) => cb(c)); }, }); diff --git a/apps/desktop/scripts/pack-agent.cjs b/apps/desktop/scripts/pack-agent.cjs index 8e42fdc..50cae08 100644 --- a/apps/desktop/scripts/pack-agent.cjs +++ b/apps/desktop/scripts/pack-agent.cjs @@ -1,12 +1,61 @@ "use strict"; -// Bundle the agent service into a single CJS file the Electron app runs. No -// native modules anymore (the DB is JSON files), so everything bundles clean. +// Bundle the agent service into a single CJS file the Electron app runs. The +// JSON-file DB keeps the bundle native-free; the ONE native piece — the +// sherpa-onnx voice-cloning addon — is loaded lazily via createRequire at +// runtime (never bundled), so it ships as a real node_modules dir next to +// agent.mjs and the app still boots fine without it. const esbuild = require("esbuild"); const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); +const { execSync } = require("node:child_process"); const root = path.resolve(__dirname, "..", "..", ".."); const outdir = path.resolve(__dirname, "..", "dist", "agent"); +// The platform packages the TARGET OS needs. macOS ships BOTH archs — the DMG +// is universal and the addon picks by os.arch() at runtime; Windows/Linux ship +// their own. (Builds run on the target OS, matching the repo's release flow.) +function sherpaTargets() { + if (process.platform === "darwin") return ["darwin-arm64", "darwin-x64"]; + if (process.platform === "win32") return ["win-x64"]; + return [`linux-${os.arch()}`]; +} + +// sherpa-onnx-node resolves its prebuilt binary from a SIBLING package +// (../sherpa-onnx--/sherpa-onnx.node), so ship the JS package +// plus each target platform package next to agent.mjs. pnpm only installs the +// build machine's own arch — any missing target is fetched with `npm pack` +// (same pinned version) so a universal mac build works from either machine. +function copySherpa() { + const nmSrc = path.join(root, "services/agent/node_modules"); + const nmOut = path.join(outdir, "node_modules"); + if (!fs.existsSync(path.join(nmSrc, "sherpa-onnx-node"))) { console.warn("[pack-agent] sherpa-onnx-node not installed — packing without voice cloning"); return; } + const resolved = fs.realpathSync(path.join(nmSrc, "sherpa-onnx-node")); + const storeDir = path.dirname(resolved); + const version = JSON.parse(fs.readFileSync(path.join(resolved, "package.json"), "utf8")).version; + fs.cpSync(resolved, path.join(nmOut, "sherpa-onnx-node"), { recursive: true, dereference: true }); + + const shipped = []; + for (const t of sherpaTargets()) { + const name = `sherpa-onnx-${t}`; + const local = path.join(storeDir, name); + if (fs.existsSync(local)) { + fs.cpSync(local, path.join(nmOut, name), { recursive: true, dereference: true }); + } else { + // Cross-arch fetch (e.g. darwin-x64 on an arm64 Mac): npm pack + extract. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sherpa-pack-")); + execSync(`npm pack ${name}@${version} --pack-destination "${tmp}"`, { stdio: "pipe" }); + const tgz = fs.readdirSync(tmp).find((f) => f.endsWith(".tgz")); + execSync(`tar -xzf "${path.join(tmp, tgz)}" -C "${tmp}"`, { stdio: "pipe" }); // bsdtar ships on macOS + Win10+ + fs.cpSync(path.join(tmp, "package"), path.join(nmOut, name), { recursive: true }); + fs.rmSync(tmp, { recursive: true, force: true }); + } + shipped.push(name); + } + console.log(`[pack-agent] shipped sherpa-onnx-node@${version} + ${shipped.join(", ")}`); +} + esbuild.build({ entryPoints: [path.join(root, "services/agent/src/server.ts")], bundle: true, @@ -15,10 +64,12 @@ esbuild.build({ format: "esm", outfile: path.join(outdir, "agent.mjs"), // ws pulls these optional native speedups; it works fine without them. - external: ["bufferutil", "utf-8-validate"], + // sherpa-onnx-node is a native addon shipped as node_modules (see above). + external: ["bufferutil", "utf-8-validate", "sherpa-onnx-node"], // Some bundled CJS deps reference these — shim them for the ESM output. banner: { js: "import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" }, logLevel: "info", }).then(() => { + copySherpa(); console.log("[pack-agent] wrote dist/agent/agent.mjs"); }).catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index f4eb29f..12c16f5 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -13,9 +13,13 @@ import { join } from "node:path"; // ponytail: this widens the canvas-script exfiltration surface to the HF/jsdelivr // hosts (not arbitrary). Acceptable pre-launch; to fully re-fence, proxy model // downloads through a same-origin /api route with a host allowlist. +// VAD assets are vendored same-origin (public/vad); jsdelivr stays allowed only +// because transformers.js can fall back to it for its own ort wasm loader. const MODEL_HOSTS = "https://huggingface.co https://*.huggingface.co https://*.hf.co https://cdn.jsdelivr.net"; -// Live voice connects to the agent over a WebSocket (direct, not via the Next -// proxy). Allow its origin in connect-src, derived from env (dev: ws://localhost:8787). +// Live voice connects to the agent over a WebSocket. Desktop/dev: directly to the +// agent port (NEXT_PUBLIC_LIVE_WS_URL / localhost). Container: same-origin through +// the server.mjs proxy, which injects the shared secret (browsers can't set that +// header themselves). Both origins stay in connect-src. const LIVE_WS = (() => { const raw = process.env.NEXT_PUBLIC_LIVE_WS_URL || `ws://localhost:${process.env.AGENT_PORT || 8787}`; try { return new URL(raw).origin; } catch { return "ws://localhost:8787"; } diff --git a/apps/web/package.json b/apps/web/package.json index ae226c5..d5de8b2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,15 +1,18 @@ { "name": "@openlive/web", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { - "dev": "NEXT_PUBLIC_LIVE_WS_URL=ws://localhost:${AGENT_PORT:-8787} next dev -p ${WEB_PORT:-3000}", + "predev": "node scripts/copy-voice-assets.mjs", + "dev": "node scripts/dev.mjs", + "prebuild": "node scripts/copy-voice-assets.mjs", "build": "next build", "start": "node server.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { + "@gsap/react": "^2.1.2", "@huggingface/transformers": "^4.2.0", "@openlive/db": "workspace:*", "@openlive/harness": "workspace:*", @@ -17,14 +20,16 @@ "@ricky0123/vad-web": "^0.0.29", "@tanstack/react-query": "^5.101.2", "clsx": "^2.1.1", + "gsap": "^3.15.0", "kokoro-js": "^1.2.0", "lucide-react": "^0.469.0", - "motion": "^12.42.0", "next": "^16.2.9", "next-themes": "^0.4.6", "onnxruntime-web": "^1.22.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^2.6.0", "ws": "^8.18.0", "zustand": "^5.0.3" diff --git a/apps/web/public/agents/claude.svg b/apps/web/public/agents/claude.svg new file mode 100644 index 0000000..e29f328 --- /dev/null +++ b/apps/web/public/agents/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/apps/web/public/agents/codex.svg b/apps/web/public/agents/codex.svg new file mode 100644 index 0000000..78caf4f --- /dev/null +++ b/apps/web/public/agents/codex.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/apps/web/public/agents/cursor.svg b/apps/web/public/agents/cursor.svg new file mode 100644 index 0000000..a5b2ee3 --- /dev/null +++ b/apps/web/public/agents/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/apps/web/public/agents/hermes.svg b/apps/web/public/agents/hermes.svg new file mode 100644 index 0000000..f42c0da --- /dev/null +++ b/apps/web/public/agents/hermes.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/agents/opencode.svg b/apps/web/public/agents/opencode.svg new file mode 100644 index 0000000..12ea009 --- /dev/null +++ b/apps/web/public/agents/opencode.svg @@ -0,0 +1 @@ +OpenCode diff --git a/apps/web/scripts/copy-voice-assets.mjs b/apps/web/scripts/copy-voice-assets.mjs new file mode 100644 index 0000000..7300271 --- /dev/null +++ b/apps/web/scripts/copy-voice-assets.mjs @@ -0,0 +1,32 @@ +// Vendor the VAD runtime assets (Silero onnx + audio worklet + ort wasm loader) +// from node_modules into public/vad/ so the voice loop starts without touching a +// CDN. Runs before dev and build; public/vad is gitignored (binaries stay out of +// the repo, versions track package.json). +import { copyFileSync, mkdirSync, existsSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = join(here, "..", "public", "vad"); +mkdirSync(out, { recursive: true }); + +// Resolved via the app's node_modules symlinks (pnpm); realpath for good measure. +// require.resolve can't be used — onnxruntime-web doesn't export ./package.json. +const nm = (pkg) => realpathSync(join(here, "..", "node_modules", pkg)); +const vadDist = join(nm("@ricky0123/vad-web"), "dist"); +const ortDist = join(nm("onnxruntime-web"), "dist"); + +const files = [ + [vadDist, "silero_vad_v5.onnx"], + [vadDist, "vad.worklet.bundle.min.js"], + // The VAD runs on the plain CPU wasm backend — only the threaded-simd pair is loaded. + [ortDist, "ort-wasm-simd-threaded.mjs"], + [ortDist, "ort-wasm-simd-threaded.wasm"], +]; + +for (const [dir, name] of files) { + const src = join(dir, name); + if (!existsSync(src)) { console.error(`copy-voice-assets: missing ${src}`); process.exit(1); } + copyFileSync(src, join(out, name)); +} +console.log(`copy-voice-assets: vendored ${files.length} files → public/vad/`); diff --git a/apps/web/scripts/dev.mjs b/apps/web/scripts/dev.mjs new file mode 100644 index 0000000..6b69b11 --- /dev/null +++ b/apps/web/scripts/dev.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +// Cross-platform `next dev` launcher. The old script used POSIX-only inline env + +// ${VAR:-default} expansion, which breaks on Windows cmd/powershell. +import { spawn } from "node:child_process"; + +const agentPort = process.env.AGENT_PORT ?? "8787"; +const webPort = process.env.WEB_PORT ?? "3000"; +const child = spawn("next", ["dev", "-p", webPort], { + stdio: "inherit", + shell: process.platform === "win32", // .cmd shims need a shell on Windows + env: { ...process.env, NEXT_PUBLIC_LIVE_WS_URL: `ws://localhost:${agentPort}` }, +}); +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/apps/web/src/app/api/agents/action/route.ts b/apps/web/src/app/api/agents/action/route.ts new file mode 100644 index 0000000..349209a --- /dev/null +++ b/apps/web/src/app/api/agents/action/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { spawn } from "node:child_process"; +import { widenedPath } from "@openlive/shared/node"; +import { actionCommand, agentById, type Action } from "../agents"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Run install / uninstall / login / update for one agent and stream the process +// output back as plain text so the panel can show it live. install/uninstall run +// headless; login opens the agent's own browser sign-in (a Terminal on macOS) and +// returns quickly. A failed global npm install from a root-owned prefix (EACCES) +// gets actionable guidance appended instead of just a raw dump. +const NPM_EACCES_HELP = ` +⚠ npm can't write to its global folder (permission denied). +Fix it once, then retry: + mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global + (add ~/.npm-global/bin to your PATH) +Or install Node via Homebrew or nvm, which use a user-writable prefix. +`; + +// A terminal launch that failed never reached the user's shell — on macOS the +// usual culprit is the Automation permission (osascript error -1743), which +// fails with nothing visible on screen. Always give the manual path. +const terminalHelp = (display?: string) => ` +⚠ Couldn't open your terminal automatically. +${process.platform === "darwin" + ? "macOS may be blocking automation: System Settings → Privacy & Security → Automation → allow OpenLive to control Terminal." + : "Your system blocked launching a terminal window from OpenLive."} +${display ? `Run this yourself in any terminal, then hit Re-check:\n ${display}\n` : ""}`; + +export async function POST(req: Request) { + const { id, action } = (await req.json().catch(() => ({}))) as { id?: string; action?: Action }; + const agent = id ? agentById(id) : undefined; + const spec = agent && action ? actionCommand(agent, action) : null; + if (!agent || !action || !spec) return NextResponse.json({ error: "Unknown agent or action." }, { status: 400 }); + + // Windows: `npm` is a `.cmd` shim Node won't exec without a shell (ENOENT) — every + // Install/Update button died there. Real .exe launchers (powershell/cmd) don't need + // it. POSIX keeps shell:false. + const useShell = process.platform === "win32" && spec.cmd === "npm"; + const child = spawn(spec.cmd, spec.args, { shell: useShell, env: { ...process.env, PATH: widenedPath() } }); + const enc = new TextEncoder(); + let sawEacces = false; + const stream = new ReadableStream({ + start(controller) { + const push = (s: string) => { try { controller.enqueue(enc.encode(s)); } catch { /* closed */ } }; + const watch = (s: string) => { if (/EACCES|EPERM|permission denied/i.test(s)) sawEacces = true; return s; }; + push(`$ ${spec.cmd} ${spec.args.join(" ")}\n`); + child.stdout.on("data", (d: Buffer) => push(watch(d.toString()))); + child.stderr.on("data", (d: Buffer) => push(watch(d.toString()))); + child.on("error", (e) => { + push(`\n[error] ${e.message}\n`); + if (spec.terminal) push(terminalHelp(spec.display)); + controller.close(); + }); + child.on("close", (code) => { + if (spec.cmd === "npm" && code !== 0 && sawEacces) push(NPM_EACCES_HELP); + if (spec.terminal && code !== 0) push(terminalHelp(spec.display)); + push( + spec.terminal && code === 0 + ? "\n✓ Continues in the terminal window that opened — finish there; the status updates by itself.\n" + : `\n[exit ${code ?? 0}]\n`, + ); + controller.close(); + }); + }, + cancel() { child.kill(); }, + }); + return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" } }); +} diff --git a/apps/web/src/app/api/agents/agents.ts b/apps/web/src/app/api/agents/agents.ts new file mode 100644 index 0000000..fb066fe --- /dev/null +++ b/apps/web/src/app/api/agents/agents.ts @@ -0,0 +1,38 @@ +// Actions for the Agents panel, driven by the shared registry (the single source +// of agent identity/install/auth facts). Each agent runs on THIS machine with the +// user's own login; OpenLive just reports status and can install/remove the CLI +// or open its sign-in/sign-out flow. + +import { AGENT_REGISTRY, isAgentId, type AgentDef } from "@openlive/shared"; +import { terminalCommand } from "@openlive/shared/node"; + +export type Action = "install" | "uninstall" | "login" | "logout" | "update"; + +export const agentById = (id: string): AgentDef | undefined => (isAgentId(id) ? AGENT_REGISTRY[id] : undefined); + +// The command to run for an action. npm/shell installs are non-interactive +// (streamed inline); login/logout and terminal-flavored installs (hermes' +// wizard) open the user's terminal (Terminal.app on macOS, cmd on Windows). +// EACCES on global npm installs gets actionable guidance appended to the +// stream (see action/route.ts). "update" reruns the install recipe — npm +// pins @latest explicitly; the curl scripts always fetch the latest anyway. +export function actionCommand(a: AgentDef, action: Action): { cmd: string; args: string[]; terminal?: boolean; display?: string } | null { + const isWin = process.platform === "win32"; + // `display` = the human-runnable command behind a terminal launch, so a failed + // launch (macOS Automation denied) can tell the user what to run themselves. + if (action === "login") { const raw = (isWin && a.winLogin) || a.login; return { ...terminalCommand(raw), terminal: true, display: raw }; } + if (action === "logout") return a.logout ? { ...terminalCommand(a.logout), terminal: true, display: a.logout } : null; + + const recipe = action === "uninstall" ? a.uninstall : a.install; + if (!recipe) return null; + const terminalRecipe = (isWin && recipe.winTerminal) || recipe.terminal; + if (action === "update" && terminalRecipe) return null; // interactive installs (hermes) manage their own version + // Interactive installs (hermes' setup wizard) run in the user's terminal. + if (terminalRecipe) return { ...terminalCommand(terminalRecipe), terminal: true, display: terminalRecipe }; + if (recipe.npm) return { cmd: "npm", args: [action === "uninstall" ? "uninstall" : "install", "-g", action === "update" ? `${recipe.npm}@latest` : recipe.npm] }; + const shell = process.platform === "win32" ? recipe.winShell : recipe.posixShell; + if (!shell) return null; + return process.platform === "win32" + ? { cmd: "powershell", args: ["-NoProfile", "-Command", shell] } + : { cmd: "bash", args: ["-lc", shell] }; +} diff --git a/apps/web/src/app/api/agents/route.ts b/apps/web/src/app/api/agents/route.ts new file mode 100644 index 0000000..6c062ec --- /dev/null +++ b/apps/web/src/app/api/agents/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { homedir } from "node:os"; +import { AGENT_LIST } from "@openlive/shared"; +import { widenedPath, evalCredProbe, readJsonHome, type CredState } from "@openlive/shared/node"; +import { getSetting } from "@openlive/db"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Status for the Agents panel: is each agent's CLI installed, is it signed in +// (read-only credential probe — file/JSON/keychain presence, never the secret), +// and is it hidden from selectors. Session dirs are the tools' own stores. +async function present(bin: string): Promise { + const finder = process.platform === "win32" ? "where" : "which"; + try { await promisify(execFile)(finder, [bin], { env: { ...process.env, PATH: widenedPath() }, timeout: 3000 }); return true; } + catch { return false; } +} + +/** ` --version` → a short version string, or undefined. Best-effort: some + * CLIs print banners — keep just the first line, capped. */ +async function binVersion(bin: string): Promise { + try { + const { stdout } = await promisify(execFile)(bin, ["--version"], { env: { ...process.env, PATH: widenedPath() }, timeout: 4000 }); + const line = stdout.trim().split("\n")[0]?.trim(); + return line ? line.slice(0, 48) : undefined; + } catch { return undefined; } +} + +/** A human detail for the signed-in state where the store exposes one (cursor + * keeps the account email in its CLI config). */ +function authDetail(id: string): string | undefined { + if (id !== "cursor") return undefined; + const info = readJsonHome("~/.cursor/cli-config.json")?.authInfo as { email?: string } | undefined; + return typeof info?.email === "string" ? info.email : undefined; +} + +export async function GET() { + const home = homedir(); + const rows = await Promise.all(AGENT_LIST.map(async (a) => { + // Installed = runner binary on PATH, AND (where the binary alone proves + // nothing — hermes via uvx) the agent's own footprint exists. + const presentBins = await Promise.all(a.bins.map(async (b) => ((await present(b)) ? b : null))); + const firstBin = presentBins.find(Boolean) ?? null; + const installed = !!firstBin && (!a.installedProbe || (await evalCredProbe(a.installedProbe)) === "ready"); + // Only probe credentials when actually installed — a leftover config file + // from an old install shouldn't render as signed in. + const credState: CredState = installed ? await evalCredProbe(a.credProbe) : "unknown"; + // CLI version, shown in the row. Skipped for hermes — its runner bin is uvx, + // whose --version reports uv, not hermes. + const version = installed && firstBin && a.id !== "hermes" ? await binVersion(firstBin) : undefined; + return { + id: a.id, label: a.label, + installed, + credState, + version, + authDetail: credState === "ready" ? authDetail(a.id) : undefined, + // Wizard-style agents (hermes): sign-in IS the setup flow, so the UI says + // "Setup incomplete"/"Finish setup" instead of "Sign in needed"/"Sign in". + wizard: !!a.wizard, + // The raw sign-in command, for the Copy button — the manual path when + // opening a terminal automatically is blocked (macOS Automation). + loginCommand: (process.platform === "win32" && a.winLogin) || a.login, + canInstall: !!a.install, + canUninstall: !!a.uninstall, + canLogout: !!a.logout, + // Update = rerun the headless install recipe with @latest; terminal-wizard + // installs (hermes, pinned) manage their own version. + canUpdate: installed && !!a.install && !a.install.terminal, + hidden: getSetting(`agentHidden:${a.id}`) === "1", + sessions: a.sessionsDir, home, + }; + })); + return NextResponse.json(rows); +} diff --git a/apps/web/src/app/api/chats/[id]/route.ts b/apps/web/src/app/api/chats/[id]/route.ts index 2447b21..a79c585 100644 --- a/apps/web/src/app/api/chats/[id]/route.ts +++ b/apps/web/src/app/api/chats/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { listMessages, deleteChat } from "@openlive/db"; +import { listMessages, deleteChat, renameChat } from "@openlive/db"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -10,8 +10,18 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str return NextResponse.json(listMessages(id)); } +// Rename an OpenLive conversation (History → session → Rename). +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { title } = (await req.json().catch(() => ({}))) as { title?: string }; + const t = title?.trim(); + if (!t) return NextResponse.json({ error: "Title required." }, { status: 400 }); + await renameChat(id, t); + return NextResponse.json({ ok: true }); +} + export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; - deleteChat(id); + await deleteChat(id); return NextResponse.json({ ok: true }); } diff --git a/apps/web/src/app/api/history/agentSessions.ts b/apps/web/src/app/api/history/agentSessions.ts new file mode 100644 index 0000000..747220a --- /dev/null +++ b/apps/web/src/app/api/history/agentSessions.ts @@ -0,0 +1,240 @@ +import { readFileSync, readdirSync, statSync, existsSync, rmSync, openSync, readSync, closeSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { AGENT_LIST, type AgentDef } from "@openlive/shared"; + +// Discover each coding agent's OWN prior sessions from its on-disk storage (the +// same ones its `/resume` would show), so History can surface them alongside +// OpenLive's. Read-only + best-effort: a format change or unreadable file is +// skipped, never fatal. ACP `session/list` is still an unratified RFD (no agents +// implement it), so disk is the reliable path in July 2026. +// +// ponytail: parses agent-specific on-disk formats — brittle by nature. Capped at +// RECENT files, first LINES only for the title. If an agent changes its layout, +// that agent's sessions just stop appearing (the rest keep working). + +export interface ExternalSession { id: string; title: string; updatedAt: string; cwd: string } +export interface ExternalAgentSessions { agentId: string; sessions: ExternalSession[] } + +const RECENT = 60; // most-recent sessions per agent (by file mtime) +const TITLE_SCAN_LINES = 80; // lines to scan for a human title (past preambles) +const clip = (s: string, n = 64) => { const t = s.replace(/\s+/g, " ").trim(); return t.length > n ? `${t.slice(0, n)}…` : t; }; +const iso = (ms: number) => new Date(ms).toISOString(); + +// Skip the machine-generated preambles injected as the "first" user message — XML +// context blocks, slash-command echoes, and OpenLive's own voice preamble/seed — +// so the title reads as the user's actual first words. +const isBoilerplate = (t: string) => !t || /^\s*(<[a-z-]+|\[(you're being used|context —|image #)|base directory for this skill|# files mentioned|caveat:)/i.test(t); + +/** Read the first N lines of a file without loading the whole thing (session logs + * can be hundreds of MB — see the Codex growth issue). One bounded read: the + * cwd/title always live in the first lines, so 1 MB is plenty; a line truncated + * at the boundary just fails the caller's JSON.parse and is skipped. */ +const HEAD_BYTES = 1024 * 1024; +function headLines(path: string, max: number): string[] { + try { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(HEAD_BYTES); + const n = readSync(fd, buf, 0, HEAD_BYTES, 0); + return buf.toString("utf8", 0, Math.max(0, n)).split("\n").filter((l) => l.trim()).slice(0, max); + } finally { closeSync(fd); } + } catch { return []; } +} + +const recentFiles = (paths: { path: string; mtimeMs: number }[]) => + paths.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, RECENT); + +// ── Claude Code: ~/.claude/projects//.jsonl ────────── +function claudeSessions(): ExternalSession[] { + const root = join(homedir(), ".claude", "projects"); + if (!existsSync(root)) return []; + const files: { path: string; mtimeMs: number }[] = []; + for (const proj of safeReaddir(root)) { + const dir = join(root, proj); + for (const f of safeReaddir(dir)) { + if (!f.endsWith(".jsonl")) continue; + try { files.push({ path: join(dir, f), mtimeMs: statSync(join(dir, f)).mtimeMs }); } catch { /* skip */ } + } + } + return recentFiles(files).map(({ path, mtimeMs }) => { + const id = basename(path, ".jsonl"); + let cwd = "", title = ""; + for (const line of headLines(path, TITLE_SCAN_LINES)) { + let o: any; try { o = JSON.parse(line); } catch { continue; } + if (!cwd && typeof o.cwd === "string") cwd = o.cwd; + if (!title && o.type === "user" && o.message?.role === "user") { + const c = o.message.content; + const text = typeof c === "string" ? c : Array.isArray(c) ? c.find((b: any) => b?.type === "text")?.text ?? "" : ""; + if (!isBoilerplate(text)) title = clip(text); + } + if (cwd && title) break; + } + return { id, cwd, title: title || "Claude Code session", updatedAt: iso(mtimeMs) }; + }).filter((s) => s.cwd); +} + +// ── Codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl ────────────────────── +function codexSessions(): ExternalSession[] { + const root = join(homedir(), ".codex", "sessions"); + if (!existsSync(root)) return []; + const files: { path: string; mtimeMs: number }[] = []; + const walk = (dir: string, depth: number) => { + for (const e of safeReaddir(dir)) { + const p = join(dir, e); + let st; try { st = statSync(p); } catch { continue; } + if (st.isDirectory() && depth < 4) walk(p, depth + 1); + else if (e.startsWith("rollout-") && e.endsWith(".jsonl")) files.push({ path: p, mtimeMs: st.mtimeMs }); + } + }; + walk(root, 0); + return recentFiles(files).map(({ path, mtimeMs }) => { + let id = "", cwd = "", title = ""; + for (const line of headLines(path, TITLE_SCAN_LINES)) { + let o: any; try { o = JSON.parse(line); } catch { continue; } + const p = o.payload; + if (o.type === "session_meta" && p) { id = p.id ?? id; cwd = p.cwd ?? cwd; } + if (!title && p?.type === "message" && p.role === "user") { + const text = Array.isArray(p.content) ? p.content.find((b: any) => b?.type === "input_text")?.text ?? "" : ""; + if (!isBoilerplate(text)) title = clip(text); + } + if (id && cwd && title) break; + } + return { id: id || basename(path), cwd, title: title || "Codex session", updatedAt: iso(mtimeMs) }; + }).filter((s) => s.cwd); +} + +// ── Cursor: ~/.cursor/acp-sessions//meta.json ({ cwd }) ─────────── +function cursorSessions(): ExternalSession[] { + const root = join(homedir(), ".cursor", "acp-sessions"); + if (!existsSync(root)) return []; + const out: ExternalSession[] = []; + for (const id of safeReaddir(root)) { + const meta = join(root, id, "meta.json"); + try { + const m = JSON.parse(readFileSync(meta, "utf8")); + if (typeof m.cwd !== "string" || !m.cwd) continue; + out.push({ id, cwd: m.cwd, title: "Cursor session", updatedAt: iso(statSync(meta).mtimeMs) }); + } catch { /* skip */ } + } + return recentFiles(out.map((s) => ({ ...s, path: "", mtimeMs: new Date(s.updatedAt).getTime() })) as any).map((x: any) => ({ id: x.id, cwd: x.cwd, title: x.title, updatedAt: x.updatedAt })); +} + +// ── OpenCode: sqlite at /opencode/opencode.db (session table) ─────────── +// = $XDG_DATA_HOME or ~/.local/share — the same path on Windows too +// (verified against opencode's docs). Read-only via node:sqlite (Node ≥22.13 — +// already this repo's floor). Sub-sessions (parent_id set) are agent-internal; +// only top-level sessions are the user's. +function opencodeSessions(): ExternalSession[] { + const dataDir = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + const db = join(dataDir, "opencode", "opencode.db"); + if (!existsSync(db)) return []; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite"); + const conn = new DatabaseSync(db, { readOnly: true }); + try { + const rows = conn + .prepare("SELECT id, directory, title, time_updated FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC LIMIT ?") + .all(RECENT) as { id: string; directory: string; title: string; time_updated: number }[]; + return rows + .filter((r) => r.directory) + .map((r) => ({ id: r.id, cwd: r.directory, title: clip(r.title || "OpenCode session"), updatedAt: iso(r.time_updated) })); + } finally { conn.close(); } + } catch { return []; } // locked db / schema change / old Node → just no sessions +} + +// ── Hermes: sqlite at ~/.hermes/state.db (its canonical SessionDB) ──────────── +// Schema verified against hermes-agent 0.18.2 source (hermes_state.py): table +// `sessions`, PK `id`, columns cwd/title/started_at/ended_at (REAL unix seconds), +// parent_session_id for internal sub-sessions, archived flag. Reads stay fully +// guarded — a future schema change = no sessions, not a crash. +function hermesSessions(): ExternalSession[] { + const db = join(homedir(), ".hermes", "state.db"); + if (!existsSync(db)) return []; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite"); + const conn = new DatabaseSync(db, { readOnly: true }); + try { + const rows = conn + .prepare(`SELECT id, cwd, title, COALESCE(ended_at, started_at) AS ts FROM sessions + WHERE parent_session_id IS NULL AND archived = 0 + ORDER BY ts DESC LIMIT ?`) + .all(RECENT) as { id: string; cwd: string | null; title: string | null; ts: number }[]; + return rows.map((r) => ({ + id: String(r.id), + cwd: r.cwd ?? "", + title: clip(r.title || "Hermes session"), + updatedAt: iso(r.ts * 1000), // REAL unix seconds → ms + })).filter((s) => s.cwd); + } finally { conn.close(); } + } catch { return []; } +} + +function safeReaddir(dir: string): string[] { + try { return readdirSync(dir); } catch { return []; } +} + +// Discovery dispatch, keyed on each agent's registry `sessionParser` — a future +// agent added to the shared registry with one of these formats needs zero code here. +const PARSERS: Record ExternalSession[]> = { + "claude-jsonl": claudeSessions, + "codex-rollout": codexSessions, + "cursor-meta": cursorSessions, + "opencode-sqlite": opencodeSessions, + "hermes-sqlite": hermesSessions, +}; + +/** External sessions per agent, discovered from disk. */ +export function readExternalAgentSessions(): ExternalAgentSessions[] { + return AGENT_LIST + .map((a) => ({ agentId: a.id, sessions: PARSERS[a.sessionParser]() })) + .filter((a) => a.sessions.length > 0); +} + +// Permanently delete a coding agent's OWN on-disk session file/dir (History → +// external session → Delete). Destructive and irreversible — it removes the session +// from the agent itself, not just OpenLive. The id comes from our own history feed; +// still, refuse anything with path separators as defense-in-depth. +export function deleteExternalSession(agentId: string, id: string): boolean { + if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) return false; + const rm = (p: string) => { try { rmSync(p, { recursive: true, force: true }); return true; } catch { return false; } }; + + if (agentId === "cursor") { + const dir = join(homedir(), ".cursor", "acp-sessions", id); + return existsSync(dir) && rm(dir); + } + if (agentId === "claude-code") { + const root = join(homedir(), ".claude", "projects"); + for (const proj of safeReaddir(root)) { + const f = join(root, proj, `${id}.jsonl`); + if (existsSync(f)) return rm(f); + } + return false; + } + if (agentId === "codex") { + const root = join(homedir(), ".codex", "sessions"); + let target: string | null = null; + const walk = (dir: string, depth: number) => { + for (const e of safeReaddir(dir)) { + if (target) return; + const p = join(dir, e); + let st; try { st = statSync(p); } catch { continue; } + if (st.isDirectory() && depth < 4) walk(p, depth + 1); + else if (e.startsWith("rollout-") && e.endsWith(".jsonl")) { + if (e.includes(id)) { target = p; return; } // id embedded in the filename + for (const line of headLines(p, 5)) { // else match session_meta.id + let o: any; try { o = JSON.parse(line); } catch { continue; } + if (o.type === "session_meta" && o.payload?.id === id) { target = p; return; } + } + } + } + }; + walk(root, 0); + return target ? rm(target) : false; + } + // opencode/hermes: sessions live inside THEIR sqlite databases — never write into + // a third-party live db from here. History hides the delete affordance for these. + return false; +} diff --git a/apps/web/src/app/api/history/route.ts b/apps/web/src/app/api/history/route.ts new file mode 100644 index 0000000..f42dd55 --- /dev/null +++ b/apps/web/src/app/api/history/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { listChats, chatMessageCounts, getSetting } from "@openlive/db"; +import type { HistoryChat, HistoryWorkspace } from "@openlive/shared"; +import { readExternalAgentSessions } from "./agentSessions"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Agents toggled off in Settings disappear from History too (their sessions stay +// on disk / in the DB — un-hiding restores everything). +const isHidden = (id: string | null) => !!id && getSetting(`agentHidden:${id}`) === "1"; + +// History grouped workspace → chats: all agents' chats for the same project live +// together, newest first; each chat carries its agent for the row's brand mark. +// Merges OpenLive's own sessions (from our DB) with each coding agent's OWN +// external sessions read from disk (source:"external", resumable via ACP +// loadSession). Only truly empty chats (never spoken in) are hidden — folderless +// conversations show under a "No folder" workspace, always sorted last. +export function GET() { + const byCwd = new Map(); + const add = (cwd: string, c: HistoryChat) => { + const ws = byCwd.get(cwd) ?? { cwd, chats: [] }; + ws.chats.push(c); + byCwd.set(cwd, ws); + }; + + // OpenLive's own sessions (folderless ones grouped under "" → "No folder"). + const counts = chatMessageCounts(); + for (const c of listChats()) { + if ((counts[c.id] ?? 0) === 0) continue; // hide empty (a lobby connect never spoken in) + if (isHidden(c.agentId ?? null)) continue; + // Carry the agent's own session id so this OpenLive chat dedups against its + // on-disk agent session (below) — and so the UI can "continue in the CLI". + add(c.cwd ?? "", { id: c.id, title: c.title || "Conversation", updatedAt: c.updatedAt ?? c.createdAt, agentId: c.agentId ?? null, source: "openlive", resumeSessionId: c.agentSessionId }); + } + + // Each agent's own external sessions (from disk). Hidden agents are skipped + // entirely (no discovery work either). + const seen = new Set([...byCwd.values()].flatMap((w) => w.chats.map((s) => s.resumeSessionId ?? s.id))); + for (const a of readExternalAgentSessions()) { + if (isHidden(a.agentId)) continue; + for (const s of a.sessions) { + if (seen.has(s.id)) continue; // already surfaced as an OpenLive resume of this session + add(s.cwd, { id: s.id, title: s.title, updatedAt: s.updatedAt, agentId: a.agentId, source: "external", resumeSessionId: s.id }); + } + } + + const recent = (ws: HistoryWorkspace) => ws.chats.reduce((m, s) => (s.updatedAt > m ? s.updatedAt : m), ""); + const workspaces: HistoryWorkspace[] = [...byCwd.values()] + .map((ws) => ({ ...ws, chats: ws.chats.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : -1)) })) + // Most-recent workspace first; the folderless bucket always last. + .sort((x, y) => (x.cwd === "" ? 1 : y.cwd === "" ? -1 : recent(x) < recent(y) ? 1 : -1)); + + return NextResponse.json(workspaces); +} diff --git a/apps/web/src/app/api/history/session/route.ts b/apps/web/src/app/api/history/session/route.ts new file mode 100644 index 0000000..c906e10 --- /dev/null +++ b/apps/web/src/app/api/history/session/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { deleteExternalSession } from "../agentSessions"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Permanently delete a coding agent's OWN on-disk session (History → external +// session → Delete). Irreversible — removes it from Claude Code / Codex / Cursor, +// not just OpenLive. The UI gates this behind an explicit confirm modal. +export async function DELETE(req: Request) { + const { agentId, id } = (await req.json().catch(() => ({}))) as { agentId?: string; id?: string }; + if (!agentId || !id) return NextResponse.json({ error: "agentId and id required." }, { status: 400 }); + const ok = deleteExternalSession(agentId, id); + return NextResponse.json({ ok }, { status: ok ? 200 : 404 }); +} diff --git a/apps/web/src/app/api/providers/[id]/route.ts b/apps/web/src/app/api/providers/[id]/route.ts index a2e52bc..5d434ed 100644 --- a/apps/web/src/app/api/providers/[id]/route.ts +++ b/apps/web/src/app/api/providers/[id]/route.ts @@ -12,7 +12,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const body = await req.json().catch(() => ({})); if (body.clear) { - const provider = clearProviderKey(id); + const provider = await clearProviderKey(id); if (!provider) return NextResponse.json({ error: "not found" }, { status: 404 }); return NextResponse.json(provider); } @@ -20,7 +20,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const key = typeof body.apiKey === "string" ? body.apiKey.trim() : ""; if (!key) return NextResponse.json({ error: "Paste an API key." }, { status: 400 }); - const provider = updateProvider(id, { apiKey: key }); + const provider = await updateProvider(id, { apiKey: key }); if (!provider) return NextResponse.json({ error: "not found" }, { status: 404 }); return NextResponse.json(provider); } diff --git a/apps/web/src/app/api/providers/route.ts b/apps/web/src/app/api/providers/route.ts index 86a6b9e..69c303b 100644 --- a/apps/web/src/app/api/providers/route.ts +++ b/apps/web/src/app/api/providers/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; -import { listProviders, createProvider, updateProvider } from "@openlive/db"; +import { listProviders, upsertProviderByKind } from "@openlive/db"; import { BUILTIN_PROVIDERS } from "@openlive/harness"; +import type { ProviderKind } from "@openlive/shared"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -12,14 +13,18 @@ export function GET() { } // Upsert a key for a provider by its registry id (kind). Creates the DB row on -// first use; the first provider configured becomes the default. +// first use (atomically — no dup rows under concurrent first-time POSTs); the +// first provider configured becomes the default. export async function POST(req: Request) { - const { kind, apiKey } = (await req.json()) as { kind?: string; apiKey?: string }; + let body: { kind?: string; apiKey?: string }; + try { + body = (await req.json()) as { kind?: string; apiKey?: string }; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const { kind, apiKey } = body ?? {}; const info = BUILTIN_PROVIDERS.find((p) => p.id === kind); if (!kind || !info) return NextResponse.json({ error: "Unknown provider." }, { status: 400 }); - const existing = listProviders().find((p) => p.kind === kind); - const row = existing - ? updateProvider(existing.id, { apiKey }) - : createProvider({ name: info.name, kind, apiKey, isDefault: listProviders().length === 0 }); + const row = await upsertProviderByKind(kind as ProviderKind, info.name, apiKey); return NextResponse.json(row); } diff --git a/apps/web/src/app/api/settings/route.ts b/apps/web/src/app/api/settings/route.ts index 9aae189..602db4e 100644 --- a/apps/web/src/app/api/settings/route.ts +++ b/apps/web/src/app/api/settings/route.ts @@ -7,16 +7,56 @@ export const dynamic = "force-dynamic"; // Provider + model are chosen live in Settings; nothing hardcoded. Live effort // defaults to "auto" (lowest the model supports → smoothest voice). const DEFAULTS = { liveEffort: "auto" }; -const KEYS = ["liveModel", "liveProviderId", "liveEffort", "visionProviderId", "visionModel"]; +const KEYS = ["liveModel", "liveProviderId", "liveEffort", "visionProviderId", "visionModel", "agentCwd", "customInstructions", "narrateProgress"]; +// Per-agent config keys (acpCommand: ACP override, agentHidden: +// visibility toggle) are also readable/writable. +const PREFIXES = ["acpCommand:", "agentHidden:"]; + +const isExposed = (k: string) => KEYS.includes(k) || PREFIXES.some((p) => k.startsWith(p)); + +// The store holds secrets (exa_api_key) and the agent's private memory +// (agent_notes) alongside UI settings. NEVER dump the whole blob to the browser — +// return only the keys the settings UI legitimately reads. +function exposedSettings() { + const all = getAllSettings(); + const out: Record = {}; + for (const [k, v] of Object.entries(all)) if (isExposed(k)) out[k] = v; + return out; +} + +// An acpCommand override is spawned verbatim (services/agent/src/agents/acp-agent.ts). +// Even though the settings route is loopback-only, an in-origin prompt-injected +// HTML canvas could PUT here — so constrain the value to a plain argv with no shell +// metacharacters, so it can never become `bash -c …` / `curl … | sh`. spawn() runs +// without a shell, so a value that survives this can only launch a named program +// with named args (npx/uvx/agent/opencode + package/version tokens). +function isSafeAcpCommand(v: string): boolean { + if (v.length > 512) return false; + const tokens = v.trim().split(/\s+/); + if (!tokens.length || !tokens[0]) return false; + // Each token: program name, package spec, flag, version, or path — no shell + // metacharacters, quotes, whitespace-in-arg, or control chars. + return tokens.every((t) => /^[A-Za-z0-9@._:/+=\[\]~-]+$/.test(t)); +} export function GET() { - return NextResponse.json({ ...DEFAULTS, ...getAllSettings() }); + return NextResponse.json({ ...DEFAULTS, ...exposedSettings() }); } export async function PUT(req: Request) { - const body = (await req.json()) as Record; + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + if (!body || typeof body !== "object") return NextResponse.json({ error: "Expected a JSON object" }, { status: 400 }); for (const [k, v] of Object.entries(body)) { - if (KEYS.includes(k) && typeof v === "string") setSetting(k, v); + if (typeof v !== "string" || !isExposed(k)) continue; + if (k.startsWith("acpCommand:") && v.trim() && !isSafeAcpCommand(v)) { + return NextResponse.json({ error: `Rejected unsafe command for ${k}` }, { status: 400 }); + } + await setSetting(k, v); } - return NextResponse.json({ ...DEFAULTS, ...getAllSettings() }); + return NextResponse.json({ ...DEFAULTS, ...exposedSettings() }); } diff --git a/apps/web/src/app/api/voice/[...path]/route.ts b/apps/web/src/app/api/voice/[...path]/route.ts new file mode 100644 index 0000000..dee8cd7 --- /dev/null +++ b/apps/web/src/app/api/voice/[...path]/route.ts @@ -0,0 +1,36 @@ +import type { NextRequest } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Same-origin proxy for the agent service's /voice REST surface. One path for +// every deployment: dev and desktop hop over localhost, and the container gets +// the shared secret injected server-side (browsers can't set that header). +const AGENT = `http://localhost:${process.env.AGENT_PORT || 8787}`; +const SECRET = process.env.OPENLIVE_AGENT_SECRET?.trim() || ""; + +async function forward(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params; + const url = `${AGENT}/voice/${path.join("/")}${req.nextUrl.search}`; + const res = await fetch(url, { + method: req.method, + headers: { + "content-type": req.headers.get("content-type") ?? "application/json", + ...(SECRET ? { "x-openlive-secret": SECRET } : {}), + }, + body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body, + // @ts-expect-error node fetch needs duplex for streamed request bodies + duplex: "half", + }); + // Stream the body through (download progress + PCM depend on it). + return new Response(res.body, { + status: res.status, + headers: { + "content-type": res.headers.get("content-type") ?? "application/octet-stream", + ...(res.headers.get("x-sample-rate") ? { "x-sample-rate": res.headers.get("x-sample-rate")! } : {}), + "cache-control": "no-cache", + }, + }); +} + +export { forward as GET, forward as POST, forward as DELETE, forward as PATCH }; diff --git a/apps/web/src/app/api/workspace/route.ts b/apps/web/src/app/api/workspace/route.ts new file mode 100644 index 0000000..c1616db --- /dev/null +++ b/apps/web/src/app/api/workspace/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { statSync } from "node:fs"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Pre-call check: does the chosen project folder actually exist (and is it a +// directory)? The lobby gates Start on this — a deleted/renamed/typo'd folder +// used to surface only as a confusing mid-call failure. +export async function GET(req: Request) { + const path = new URL(req.url).searchParams.get("path") ?? ""; + if (!path) return NextResponse.json({ ok: false, reason: "empty" }); + try { + const s = statSync(path); + return NextResponse.json(s.isDirectory() ? { ok: true } : { ok: false, reason: "not-a-folder" }); + } catch { + return NextResponse.json({ ok: false, reason: "missing" }); + } +} diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx index dd11e34..41b0fba 100644 --- a/apps/web/src/app/global-error.tsx +++ b/apps/web/src/app/global-error.tsx @@ -2,22 +2,32 @@ // Catches render errors anywhere in the tree so a crash shows a recovery screen // instead of a blank window. global-error replaces the root layout, so it ships -// its own /. +// its own / — and no stylesheet, so colors are inlined. They follow +// the user's OS theme via a media query (globals.css isn't loaded here) and use +// the brand accent, not an off-palette indigo. export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) { return ( - + + +

Something went wrong

-

+

OpenLive hit an unexpected error. You can reload without losing your saved settings or chats.

- - {error?.message &&
{error.message}
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 58f080e..c92305e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,6 +1,6 @@ @import "tailwindcss"; -/* ─── Takt design tokens ─────────────────────────────────────────────────── +/* ─── OpenLive design tokens ─────────────────────────────────────────────── Token *names* are mapped to CSS custom properties via @theme inline, so the Tailwind utilities (bg-card, text-accent, …) resolve to var() at runtime. The actual values live in :root (warm-paper light) and .dark (near-black), @@ -25,6 +25,11 @@ --color-danger: var(--destructive); --color-success: var(--success); --color-link-foreground: var(--link-foreground); + --color-surface-raised: var(--surface-raised); + + --shadow-xs: var(--shadow-xs); + --shadow-card: var(--shadow-card); + --shadow-pop: var(--shadow-pop); --text-chat: 13px; --text-chat--line-height: 21px; @@ -38,19 +43,22 @@ --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; } -/* Light — "warm paper": warm off-white page, white cards, near-black ink. */ +/* Light — "warm paper": a stepped oat-to-cream ladder, no pure white anywhere. + Hierarchy mirrors dark mode's 4 clean levels: page < surface-raised < card < + elevated/popover. Cards used to be #ffffff on a near-white page (Δ≈4/255) + with 9%-alpha borders — everything fused into one white field. */ :root { color-scheme: light; - --background: #f5f4f1; - --surface: #ecebe6; - --card: #ffffff; - --elevated: #ffffff; - --popover: #ffffff; + --background: #efede8; + --surface: #e7e4dd; + --card: #faf8f3; + --elevated: #fcfaf6; + --popover: #fcfaf6; --foreground: #1a1a18; --muted-foreground: #6a6a66; --faint: rgba(0, 0, 0, 0.45); - --border: rgba(0, 0, 0, 0.09); - --border-heavy: rgba(0, 0, 0, 0.16); + --border: rgba(0, 0, 0, 0.14); + --border-heavy: rgba(0, 0, 0, 0.22); --accent: #2f6fed; --accent-foreground: #ffffff; --accent-soft: rgba(47, 111, 237, 0.10); @@ -62,7 +70,12 @@ --scrollbar-thumb: rgba(0, 0, 0, 0.16); --scrollbar-thumb-hover: rgba(0, 0, 0, 0.26); - --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px -12px rgba(0, 0, 0, 0.12); + /* Elevation scale — hierarchy comes from surface shifts + these soft shadows, + not boxes of borders (HIG-style de-boxing). xs ≈ a hairline substitute. */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.07); + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.05), 0 8px 24px -12px rgba(0, 0, 0, 0.14); + --shadow-pop: 0 4px 12px rgba(0, 0, 0, 0.09), 0 24px 48px -16px rgba(0, 0, 0, 0.24); + --surface-raised: #f4f2ec; /* Shared motion vocabulary (used by CSS keyframes and JS presets alike). */ --ease-out-quart: cubic-bezier(0.22, 1, 0.36, 1); @@ -93,7 +106,10 @@ --scrollbar-thumb: rgba(255, 255, 255, 0.14); --scrollbar-thumb-hover: rgba(255, 255, 255, 0.24); + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.5); --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px -16px rgba(0, 0, 0, 0.6); + --shadow-pop: 0 4px 12px rgba(0, 0, 0, 0.5), 0 24px 48px -16px rgba(0, 0, 0, 0.7); + --surface-raised: #17171a; } html, body { @@ -129,18 +145,34 @@ body::before { .desktop .app-drag :is(button, a, input, select, textarea, label, [role="button"], [data-interactive]) { -webkit-app-region: no-drag; } /* ─── Scrollbars ──────────────────────────────────────────────────────────── */ -.takt-scroll { +/* Markdown inside the transcript (agent replies). Tailwind preflight strips + list/heading/paragraph styling — restore a compact, chat-scaled subset. */ +.ol-md > :first-child { margin-top: 0; } +.ol-md > :last-child { margin-bottom: 0; } +.ol-md p { margin: 0.4em 0; white-space: pre-wrap; } +.ol-md ul, .ol-md ol { margin: 0.4em 0; padding-left: 1.3em; } +.ol-md ul { list-style: disc; } +.ol-md ol { list-style: decimal; } +.ol-md li { margin: 0.15em 0; } +.ol-md h1, .ol-md h2, .ol-md h3, .ol-md h4 { font-weight: 600; margin: 0.7em 0 0.3em; font-size: 1.05em; } +.ol-md blockquote { border-left: 2px solid var(--border-heavy); padding-left: 0.8em; color: var(--muted-foreground); margin: 0.4em 0; } +.ol-md hr { border: 0; border-top: 1px solid var(--border); margin: 0.7em 0; } +.ol-md table { border-collapse: collapse; margin: 0.5em 0; font-size: 12px; display: block; overflow-x: auto; } +.ol-md th, .ol-md td { border: 1px solid var(--border); padding: 3px 8px; text-align: left; } +.ol-md strong { font-weight: 600; } + +.openlive-scroll { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; } -.takt-scroll::-webkit-scrollbar { width: 9px; height: 9px; } -.takt-scroll::-webkit-scrollbar-thumb { +.openlive-scroll::-webkit-scrollbar { width: 9px; height: 9px; } +.openlive-scroll::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } -.takt-scroll::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); background-clip: content-box; } +.openlive-scroll::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); background-clip: content-box; } /* ─── Interaction animations ───────────────────────────────────────────────── */ @keyframes streaming-fade { @@ -161,6 +193,14 @@ body::before { } .animate-pulse-dot { animation: pulse-dot 1.6s ease-in-out infinite; } +/* Floating panel entrance — slides in from the right edge with a soft settle */ +@keyframes panel-in { + from { opacity: 0; transform: translateX(14px); } + to { opacity: 1; transform: translateX(0); } +} +.ol-panel-in { animation: panel-in 320ms var(--ease-out-quart); } +@media (prefers-reduced-motion: reduce) { .ol-panel-in { animation: none; } } + /* Live stage entrance — a gentle fade + settle when a call becomes active */ @keyframes live-in { from { opacity: 0; transform: scale(0.985) translateY(8px); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1d3382c..28b4f13 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Providers } from "./providers"; import { WindowControls } from "@/components/WindowControls"; +import { Toasts } from "@/components/Toasts"; import "./globals.css"; const geistSans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }); @@ -9,7 +10,7 @@ const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" export const metadata: Metadata = { title: "OpenLive", - description: "A live voice + vision AI assistant — talk to it, show it your camera, and it talks back in real time.", + description: "Ears, eyes, and a voice for your AI. Bring your own model or talk to your coding agents — the whole voice loop runs on your device.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -18,6 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + ); diff --git a/apps/web/src/app/mini/page.tsx b/apps/web/src/app/mini/page.tsx new file mode 100644 index 0000000..442fc01 --- /dev/null +++ b/apps/web/src/app/mini/page.tsx @@ -0,0 +1,7 @@ +import { PanelMiniBar } from "@/components/live/PanelMiniBar"; + +// The desktop mini panel window loads this route in its own BrowserWindow. +// Pure display surface: all state arrives over IPC from the main window. +export default function MiniPage() { + return ; +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index bf921de..bedc096 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,70 +1,27 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; import { Settings2, MessageSquare, Plus } from "lucide-react"; -import { api } from "@/lib/api"; import { useUi } from "@/lib/uiStore"; import { LiveDock } from "@/components/live/LiveDock"; -import { SettingsModal } from "@/components/settings/SettingsModal"; +import { SettingsPage } from "@/components/settings/SettingsPage"; +import { HistorySidebar } from "@/components/HistorySidebar"; +import { SpotlightTour } from "@/components/SpotlightTour"; +import { AgentSelect } from "@/components/live/AgentControls"; import { OpenLiveMark } from "@/components/OpenLiveMark"; import { useAppVersion } from "@/lib/useAppVersion"; +import { setConversationBind } from "@/lib/live/useLiveSession"; +import { useLiveStore } from "@/lib/live/liveStore"; import { loadModels, modelsCached, modelsReady } from "@/lib/live/models"; -function relTime(iso: string): string { - const t = new Date(iso).getTime(); - if (!t) return ""; - const s = Math.floor((Date.now() - t) / 1000); - if (s < 60) return "just now"; - if (s < 3600) return `${Math.floor(s / 60)}m ago`; - if (s < 86400) return `${Math.floor(s / 3600)}h ago`; - return `${Math.floor(s / 86400)}d ago`; -} - -// Resume dropdown: shows saved conversations; picking one resumes it (its context -// rehydrates) and drops into the lobby (where camera/mic/model options live). -function ResumeMenu({ onPick }: { onPick: (id: string) => void }) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const { data: chats = [] } = useQuery({ queryKey: ["chats"], queryFn: api.chats, enabled: open }); - useEffect(() => { - if (!open) return; - const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; - document.addEventListener("mousedown", onDoc); - return () => document.removeEventListener("mousedown", onDoc); - }, [open]); - return ( -
- - {open && ( -
-
- {chats.length === 0 &&

No saved conversations yet.

} - {chats.map((c) => ( - - ))} -
-
- )} -
- ); -} - export default function Home() { const appVersion = useAppVersion(); const liveOpen = useUi((s) => s.liveOpen); const setLiveOpen = useUi((s) => s.setLiveOpen); const openSettings = useUi((s) => s.openSettings); + const setHistoryOpen = useUi((s) => s.setHistoryOpen); const activeChatId = useUi((s) => s.activeChatId); const newConversation = useUi((s) => s.newConversation); - const resumeChat = useUi((s) => s.resumeChat); const minimized = useUi((s) => s.minimized); // Warm the on-device voice models in the background as soon as the app loads, so @@ -75,8 +32,13 @@ export default function Home() { if (modelsCached() && !modelsReady()) void loadModels(() => {}).catch(() => {}); }, []); - const startNew = () => { newConversation(); setLiveOpen(true); }; - const resume = (id: string) => { resumeChat(id); setLiveOpen(true); }; + const startNew = () => { + newConversation(); + // Carry the hero's "Talk to" pick onto the freshly created conversation. + const pick = useLiveStore.getState().boundAgent; + if (pick) setConversationBind(useUi.getState().activeChatId, pick); + setLiveOpen(true); + }; return (
@@ -99,11 +61,23 @@ export default function Home() {

- - + + +
+ {/* Choose what a new conversation talks to — the built-in assistant or a + coding agent (Claude Code / Codex / Cursor). Carried into "New". */} +
+ Talk to
@@ -116,7 +90,16 @@ export default function Home() { )} {liveOpen && setLiveOpen(false)} />} - {!minimized && } + {!minimized && } + {!minimized && } + {!minimized && ( + + )}
); } diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index fb36588..d7776e0 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "next-themes"; import { useEffect, useState, type ReactNode } from "react"; import { useUi } from "@/lib/uiStore"; +import { warmupOnLaunch } from "@/lib/live/warmup"; export function Providers({ children }: { children: ReactNode }) { const [client] = useState( @@ -13,6 +14,9 @@ export function Providers({ children }: { children: ReactNode }) { useEffect(() => { (window as unknown as { openlive?: { onOpenSettings?: (cb: () => void) => void } }) .openlive?.onOpenSettings?.(() => useUi.getState().openSettings()); + // Quietly pre-warm the voice stack (cached weights + shaders + mic driver) + // so the first call doesn't pay the cold start. + warmupOnLaunch(); }, []); return ( diff --git a/apps/web/src/components/HistorySidebar.tsx b/apps/web/src/components/HistorySidebar.tsx new file mode 100644 index 0000000..38aa7b4 --- /dev/null +++ b/apps/web/src/components/HistorySidebar.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { X, ChevronRight, Folder, Plus, Pencil, Trash2, AlertTriangle, Search } from "lucide-react"; +import { api } from "@/lib/api"; +import { useUi } from "@/lib/uiStore"; +import { setConversationBind, setConversationFolder, setConversationResume } from "@/lib/live/useLiveSession"; +import { AgentIcon } from "./live/AgentIcon"; +import { OpenLiveOrb } from "./OpenLiveOrb"; +import { usePersistedOpen } from "@/lib/disclosure"; +import { useHistoryOverrides } from "@/lib/historyOverrides"; +import { gsap, useGSAP, DUR, EASE, prefersReduced } from "@/lib/gsap"; +import { cn } from "@/lib/cn"; +import { isDesktop, isMacDesktop, basename } from "@/lib/platform"; +import type { AgentId } from "@/lib/live/liveClient"; +import { AGENT_REGISTRY, agentLabel, isAgentId } from "@openlive/shared"; +import type { HistoryChat, HistoryWorkspace } from "@openlive/shared"; +import { SpotlightTour } from "@/components/SpotlightTour"; +import { log } from "@/lib/log"; +import { toast } from "@/lib/toast"; + +type ResumeFn = (c: HistoryChat, cwd: string) => void; +// A pending destructive action → the confirm modal. `run` performs it. +interface PendingDelete { title: string; body: string; run: () => Promise } +type RequestDelete = (r: PendingDelete) => void; + +// External sessions we can delete are plain files/dirs; opencode/hermes keep theirs +// inside live sqlite databases we won't write into — no delete affordance for those. +const canDeleteExternal = (id: string | null) => !!id && isAgentId(id) && AGENT_REGISTRY[id].externalDeletable; +function relTime(iso: string): string { + const t = new Date(iso).getTime(); + if (!t) return ""; + const s = Math.floor((Date.now() - t) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +// Left History sidebar: workspace → chats (all agents' chats for a project +// together, each row wearing its agent's mark). New Chat pinned on top, search +// across titles + workspace names, rename/delete on hover, deletes gated by a +// confirm modal. Collapse state persists per workspace. +export function HistorySidebar() { + const open = useUi((s) => s.historyOpen); + const setOpen = useUi((s) => s.setHistoryOpen); + const resumeChat = useUi((s) => s.resumeChat); + const setLiveOpen = useUi((s) => s.setLiveOpen); + const activeChatId = useUi((s) => s.activeChatId); + const root = useRef(null); + const backdrop = useRef(null); // sibling of root — animate via ref, not a scoped selector + const [visible, setVisible] = useState(false); + const [pending, setPending] = useState(null); + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const qc = useQueryClient(); + const { data: workspaces = [], isLoading } = useQuery({ queryKey: ["history", "v2"], queryFn: api.history, enabled: open }); + const overrides = useHistoryOverrides((st) => st.titles); + + useEffect(() => { if (open) setVisible(true); else { setQuery(""); setSearching(false); } }, [open]); + + const { contextSafe } = useGSAP(() => { + if (!visible || prefersReduced()) return; + gsap.fromTo(root.current, { xPercent: -100, autoAlpha: 0.6 }, { xPercent: 0, autoAlpha: 1, duration: DUR.slow, ease: EASE.out }); + gsap.fromTo(backdrop.current, { autoAlpha: 0 }, { autoAlpha: 1, duration: DUR.base, ease: EASE.soft }); + }, { scope: root, dependencies: [visible] }); + + const close = contextSafe(() => { + const done = () => { setVisible(false); setOpen(false); }; + if (!root.current || prefersReduced()) { done(); return; } + gsap.to(backdrop.current, { autoAlpha: 0, duration: DUR.fast, ease: EASE.soft }); + gsap.to(root.current, { xPercent: -100, autoAlpha: 0.6, duration: DUR.base, ease: EASE.out, onComplete: done }); + }); + + const resume: ResumeFn = (c, cwd) => { + // OpenLive chat → reopen it. External agent session → a fresh OpenLive + // conversation that loadSession-s the agent's own prior thread. Either way, set + // agent + workspace so the lobby shows the right setup and pre-connects. + let chatId = c.id; + if (c.source === "external") { + useUi.getState().newConversation(); + chatId = useUi.getState().activeChatId; + setConversationResume(chatId, c.resumeSessionId ?? c.id); + } else { + resumeChat(chatId); + } + setConversationBind(chatId, (c.agentId ?? null) as AgentId | null); + if (cwd) setConversationFolder(chatId, cwd); + setLiveOpen(true); + close(); + }; + + const newChat = () => { setOpen(false); useUi.getState().newConversation(); useUi.getState().setLiveOpen(true); }; + + const requestDelete: RequestDelete = (r) => setPending(r); + const runDelete = async () => { + if (!pending) return; + try { await pending.run(); } catch (e) { log.error("history", "delete:", e); toast("Couldn’t delete that conversation — try again."); } + setPending(null); + qc.invalidateQueries({ queryKey: ["history", "v2"] }); + }; + + // Search: match chat titles (incl. local rename overrides) and workspace names. + const q = query.trim().toLowerCase(); + const results = useMemo(() => { + if (!q) return null; + const out: { chat: HistoryChat; cwd: string }[] = []; + for (const ws of workspaces) { + const wsHit = basename(ws.cwd).toLowerCase().includes(q) || ws.cwd.toLowerCase().includes(q); + for (const chat of ws.chats) { + const title = (overrides[chat.id] ?? chat.title).toLowerCase(); + if (wsHit || title.includes(q)) out.push({ chat, cwd: ws.cwd }); + } + } + return out.sort((a, b) => (a.chat.updatedAt < b.chat.updatedAt ? 1 : -1)).slice(0, 60); + }, [q, workspaces, overrides]); + + if (!visible) return null; + return ( + <> +
+ + + {pending && setPending(null)} onConfirm={runDelete} />} + + + + ); +} + +// One workspace (project folder): all agents' chats for it, newest first. +// Open/closed state persists per folder across restarts. +function WorkspaceNode({ ws, activeChatId, resume, requestDelete }: { ws: HistoryWorkspace; activeChatId: string; resume: ResumeFn; requestDelete: RequestDelete }) { + const [open, setOpen] = usePersistedOpen(`hist:ws:${ws.cwd || "none"}`); + // ponytail: the history feed can list the same session id more than once — dedupe. + const chats = [...new Map(ws.chats.map((s) => [s.id, s])).values()]; + const label = ws.cwd ? basename(ws.cwd) : "No folder"; + + // opencode/hermes external sessions can't be deleted from here (live sqlite) — + // they're skipped; the agent's own tooling manages them. + const deletable = chats.filter((s) => s.source !== "external" || canDeleteExternal(s.agentId)); + const hasExternal = deletable.some((s) => s.source === "external"); + const deleteWorkspace = () => requestDelete({ + title: "Delete this workspace’s history?", + body: `Removes ${deletable.length} conversation${deletable.length === 1 ? "" : "s"} under “${label}”.${hasExternal ? " External agent sessions are deleted from disk — that can’t be undone." : ""}${deletable.length < chats.length ? ` ${chats.length - deletable.length} agent-managed session${chats.length - deletable.length === 1 ? "" : "s"} stay (manage those in the agent).` : ""}`, + run: async () => { + for (const s of deletable) { + if (s.source === "external") await api.deleteExternalSession(s.agentId ?? "", s.resumeSessionId ?? s.id); + else await api.deleteChat(s.id); + } + }, + }); + + return ( +
setOpen(e.currentTarget.open)} className="group/ws"> + + + + {label} + {chats.length} + + +
+ {chats.map((c) => ( + + ))} +
+
+ ); +} + +// One chat row: the agent's mark + title (+ workspace subtitle in search results). +// Rename + delete surface on hover. Title = an OpenLive-side override (external +// sessions) or the real title (OpenLive sessions). +function ChatRow({ c, cwd, showWorkspace, activeChatId, resume, requestDelete }: { c: HistoryChat; cwd: string; showWorkspace?: boolean; activeChatId: string; resume: ResumeFn; requestDelete: RequestDelete }) { + const qc = useQueryClient(); + const override = useHistoryOverrides((st) => st.titles[c.id]); + const setOverride = useHistoryOverrides((st) => st.setTitle); + const [editing, setEditing] = useState(false); + const title = override ?? c.title; + + const commit = (val: string) => { + setEditing(false); + const t = val.trim(); + if (!t || t === title) return; + if (c.source === "external") setOverride(c.id, t); // can't rewrite the agent's file — OpenLive-side title + else api.renameChat(c.id, t).then(() => qc.invalidateQueries({ queryKey: ["history", "v2"] })); + }; + + const del = () => requestDelete({ + title: "Delete conversation?", + body: c.source === "external" + ? `Permanently deletes “${title}” from ${agentLabel(c.agentId)}’s own history on disk. This can’t be undone.` + : `Permanently deletes “${title}” and its messages.`, + run: async () => { + if (c.source === "external") await api.deleteExternalSession(c.agentId ?? "", c.resumeSessionId ?? c.id); + else await api.deleteChat(c.id); + }, + }); + + if (editing) { + return ( + commit(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") commit((e.target as HTMLInputElement).value); if (e.key === "Escape") setEditing(false); }} + className="my-0.5 w-full rounded-lg border border-border-heavy bg-surface px-2 py-1.5 text-[12.5px] text-foreground outline-none focus:border-accent" /> + ); + } + + return ( +
+ + + + {(c.source !== "external" || canDeleteExternal(c.agentId)) && ( + + )} + +
+ ); +} + +// Permission-gated confirm before any delete. Danger-styled; Escape / backdrop cancels. +function ConfirmModal({ pending, onCancel, onConfirm }: { pending: PendingDelete; onCancel: () => void; onConfirm: () => void }) { + const [busy, setBusy] = useState(false); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onCancel]); + return ( +
+
+
+
+ +
+

{pending.title}

+

{pending.body}

+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/SpotlightTour.tsx b/apps/web/src/components/SpotlightTour.tsx new file mode 100644 index 0000000..8a4792a --- /dev/null +++ b/apps/web/src/components/SpotlightTour.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { ArrowRight, X } from "lucide-react"; +import { cn } from "@/lib/cn"; + +// Reusable first-visit SPOTLIGHT tour: dims the page with a cutout + accent ring +// around the ACTUAL control (found by [data-tour="…"]) and points an arrowed +// tooltip at it. Each surface mounts its own — the +// tour runs ONCE per id (localStorage), is fully skippable (Skip/Escape/×), and +// follows the target on resize. Pure CSS motion (fade/slide keyframes + hole +// transitions) — no imperative animation against elements that may not exist, +// which is what made the previous GSAP version spam "target not found". +export interface TourStep { target: string; title: string; body: string } + +const seenKey = (id: string) => `openlive-tour-${id}`; +export const tourSeen = (id: string): boolean => { try { return !!localStorage.getItem(seenKey(id)); } catch { return true; } }; +const markSeen = (id: string) => { try { localStorage.setItem(seenKey(id), "1"); } catch { /* private mode */ } }; + +export function SpotlightTour({ id, steps, active = true }: { id: string; steps: TourStep[]; active?: boolean }) { + const [show, setShow] = useState(false); + // Defer the start slightly so the surface's own entrance animation finishes + // and the anchors are where they'll stay. + useEffect(() => { + if (!active || tourSeen(id)) return; + const t = setTimeout(() => setShow(true), 650); + return () => clearTimeout(t); + }, [id, active]); + if (!show || !active) return null; + return { markSeen(id); setShow(false); }} />; +} + +const CARD_W = 330; + +// Inner component mounts ONLY while the tour is live, so every hook and DOM +// measurement runs against elements that actually exist. +function Tour({ steps, onClose }: { steps: TourStep[]; onClose: () => void }) { + const [step, setStep] = useState(0); + const [rect, setRect] = useState(null); + const [leaving, setLeaving] = useState(false); + const cardRef = useRef(null); + const [cardH, setCardH] = useState(190); // measured after render so vertical clamping is exact + + // Poll the anchor (rAF-throttled) so the spotlight FOLLOWS layout changes, and — + // critically — CLOSE the tour if the target disappears (the user navigated away or + // opened Settings over the surface). Without this a stale rect stranded the bubble + // on screen. Only setRect on an actual change so we don't re-render every frame. + useEffect(() => { + const sel = `[data-tour="${steps[step]!.target}"]`; + let raf = 0; + let missingSince = 0; + let prev = ""; + const tick = (t: number) => { + const el = document.querySelector(sel); + if (el) { + missingSince = 0; + const r = el.getBoundingClientRect(); + const key = `${r.left}|${r.top}|${r.width}|${r.height}`; + if (key !== prev) { prev = key; setRect(r); } + } else { + if (!missingSince) missingSince = t; + else if (t - missingSince > 400) { onClose(); return; } // anchor gone for good → don't strand + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [step, steps, onClose]); + + const close = useCallback(() => { setLeaving(true); setTimeout(onClose, 180); }, [onClose]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [close]); + + // Measure the real card height so vertical clamping keeps EVERY row (incl. the + // Done button) inside the viewport — a fixed estimate pushed it off-screen for + // tall targets. + useLayoutEffect(() => { + const h = cardRef.current?.offsetHeight; + if (h && Math.abs(h - cardH) > 1) setCardH(h); + }); + + if (!rect) return null; + const s = steps[step]!; + const last = step === steps.length - 1; + + const pad = 10; + const hole = { left: rect.left - pad, top: rect.top - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 }; + const vw = window.innerWidth, vh = window.innerHeight, gap = 14, margin = 16; + const cx = hole.left + hole.width / 2, cy = hole.top + hole.height / 2; + + // Pick the side with room — below → above → right → left. A full-height target + // (the setup panel) has no room above/below, so the card goes BESIDE it instead of + // off the top of the screen. + const fitsV = (edge: number) => edge >= cardH + gap + margin; + const fitsH = (edge: number) => edge >= CARD_W + gap + margin; + const place = + fitsV(vh - (hole.top + hole.height)) ? "below" : + fitsV(hole.top) ? "above" : + fitsH(vw - (hole.left + hole.width)) ? "right" : + fitsH(hole.left) ? "left" : "below"; + + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + let top: number, left: number; + if (place === "below" || place === "above") { + top = place === "below" ? hole.top + hole.height + gap : hole.top - cardH - gap; + left = cx - CARD_W / 2; + } else { + left = place === "right" ? hole.left + hole.width + gap : hole.left - CARD_W - gap; + top = cy - cardH / 2; + } + left = clamp(left, margin, vw - CARD_W - margin); + top = clamp(top, margin, vh - cardH - margin); + + // Arrow points back at the target from the card edge nearest it. Hidden when the + // clamp pulled the card away from that edge (the arrow would point at nothing). + const vertical = place === "below" || place === "above"; + const arrowPos = vertical ? clamp(cx - left, 20, CARD_W - 20) : clamp(cy - top, 20, cardH - 20); + const arrowOnCard = vertical + ? (place === "below" ? Math.abs(top - (hole.top + hole.height + gap)) < 2 : Math.abs(top - (hole.top - cardH - gap)) < 2) + : (place === "right" ? Math.abs(left - (hole.left + hole.width + gap)) < 2 : Math.abs(left - (hole.left - CARD_W - gap)) < 2); + const arrowSide = place === "below" ? "-top-1.5" : place === "above" ? "-bottom-1.5" : place === "right" ? "-left-1.5" : "-right-1.5"; + + return ( +
+ {/* the spotlight: one element whose giant shadow dims everything AROUND the target */} +
+
+ +
+ {arrowOnCard &&
} +
+

{s.title}

+ +
+

{s.body}

+
+
+ {steps.map((_, i) => ( +
+
+ {!last && steps.length > 1 && } + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/Toasts.tsx b/apps/web/src/components/Toasts.tsx new file mode 100644 index 0000000..969dd4b --- /dev/null +++ b/apps/web/src/components/Toasts.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { X, AlertCircle, Info } from "lucide-react"; +import { useToasts } from "@/lib/toast"; +import { cn } from "@/lib/cn"; + +// Bottom-center toast stack for user-actionable failures. Auto-dismisses (store +// handles timing); click × to dismiss sooner. Mounted once in the root layout. +export function Toasts() { + const toasts = useToasts((s) => s.toasts); + const dismiss = useToasts((s) => s.dismiss); + if (!toasts.length) return null; + return ( +
+ {toasts.map((t) => ( +
+ {t.kind === "error" + ? + : } +

{t.text}

+ +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/WindowControls.tsx b/apps/web/src/components/WindowControls.tsx index 0dcf274..dad9e83 100644 --- a/apps/web/src/components/WindowControls.tsx +++ b/apps/web/src/components/WindowControls.tsx @@ -3,11 +3,13 @@ import { useEffect, useState } from "react"; import { useUi } from "@/lib/uiStore"; -// The window is frameless (see main.cjs), so we draw our own macOS-style controls -// top-left on every screen. Hidden on the web build and while minimized (the mini -// overlay is transparent + click-through). Also tags with `.desktop` so the -// global drag/no-drag CSS applies only in the desktop app. -type Bridge = { isDesktop?: boolean; winClose?: () => void; winMin?: () => void; winZoom?: () => void }; +// The window is frameless (see main.cjs), so we draw our own window controls: +// macOS gets traffic-light dots top-LEFT; Windows/Linux get the native idiom — +// minimize/maximize/close top-RIGHT as flat hover targets. Hidden on the web +// build and while minimized (the mini overlay is transparent + click-through). +// Also tags with `.desktop` (and `.desktop-win` off-mac) so layout can +// clear the right chrome on the right platform. +type Bridge = { isDesktop?: boolean; platform?: string; winClose?: () => void; winMin?: () => void; winZoom?: () => void }; const ol = (): Bridge | undefined => (typeof window !== "undefined" ? (window as unknown as { openlive?: Bridge }).openlive : undefined); export function WindowControls() { @@ -15,10 +17,34 @@ export function WindowControls() { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); - if (ol()?.isDesktop) document.documentElement.classList.add("desktop"); + if (ol()?.isDesktop) { + document.documentElement.classList.add("desktop"); + if (ol()?.platform && ol()!.platform !== "darwin") document.documentElement.classList.add("desktop-win"); + } }, []); - if (!mounted || !ol()?.isDesktop || minimized) return null; + // The mini panel window (/mini) is a chromeless pill — no window controls there. + const isPanel = mounted && window.location.pathname.startsWith("/mini"); + if (!mounted || !ol()?.isDesktop || minimized || isPanel) return null; + + if (ol()?.platform && ol()!.platform !== "darwin") { + // Windows/Linux: flat right-aligned controls in the platform's order. + const btn = "grid h-9 w-11 place-items-center text-muted-foreground transition hover:bg-foreground/10 hover:text-foreground [-webkit-app-region:no-drag]"; + return ( +
+ + + +
+ ); + } + const dot = "size-3 rounded-full transition hover:brightness-125 active:brightness-90 [-webkit-app-region:no-drag]"; return (
diff --git a/apps/web/src/components/live/AgentBar.tsx b/apps/web/src/components/live/AgentBar.tsx new file mode 100644 index 0000000..3a85bd3 --- /dev/null +++ b/apps/web/src/components/live/AgentBar.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Folder, ChevronDown, Check, Cpu, SlidersHorizontal, FolderOpen } from "lucide-react"; +import { useLiveStore } from "@/lib/live/liveStore"; +import { setConversationFolder, setConversationModel, setConversationMode, recentFolders, cachedAgentMeta } from "@/lib/live/useLiveSession"; +import { useUi } from "@/lib/uiStore"; +import { cn } from "@/lib/cn"; +import { isDesktop, basename, bridge } from "@/lib/platform"; + +const noDrag = isDesktop ? "[-webkit-app-region:no-drag]" : ""; + +type Item = { id: string; label: string; sub?: string }; + +function PillMenu({ icon: Icon, label, title, items, current, onPick, footer }: { + icon: typeof Folder; label: string; title: string; items: Item[]; current?: string | null; + onPick: (id: string) => void; footer?: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", h); + return () => document.removeEventListener("mousedown", h); + }, [open]); + return ( +
+ + {open && ( +
+
{title}
+
+ {items.map((it) => ( + + ))} +
+ {footer &&
setOpen(false)}>{footer}
} +
+ )} +
+ ); +} + +/** Top-bar controls for the bound agent: project folder (with recents + Browse), + * and model / mode once the agent connects. Sits beside the agent selector so you + * see and change what you're working on at the top of the screen, mid-conversation. */ +export function AgentBar() { + const activeChatId = useUi((s) => s.activeChatId); + const boundAgent = useLiveStore((s) => s.boundAgent); + const boundCwd = useLiveStore((s) => s.boundCwd); + const liveMeta = useLiveStore((s) => s.agentMeta); + if (!boundAgent || !activeChatId) return null; + // Live meta when the agent has reported in; otherwise the per-agent cache (same + // fallback the lobby uses) — so the model/mode chips don't blink out whenever a + // reconnect/rebind clears the store before the agent re-reports. + const agentMeta = liveMeta ?? cachedAgentMeta(boundAgent); + + const folderItems: Item[] = recentFolders().map((f) => ({ id: f, label: basename(f), sub: f })); + const b = bridge; + const browse = async () => { if (!b) return; const p = await b("pick_folder"); if (p) setConversationFolder(activeChatId, p); }; + + const model = agentMeta?.models.find((m) => m.id === agentMeta.currentModelId); + const mode = agentMeta?.modes.find((m) => m.id === agentMeta.currentModeId); + + return ( +
+ · + setConversationFolder(activeChatId, id)} + footer={b && ( + + )} /> + {agentMeta && agentMeta.models.length > 1 && ( + ({ id: m.id, label: m.name }))} + current={agentMeta.currentModelId} onPick={setConversationModel} /> + )} + {agentMeta && agentMeta.modes.length > 1 && ( + ({ id: m.id, label: m.name }))} + current={agentMeta.currentModeId} onPick={setConversationMode} /> + )} + {agentMeta?.resumeAcrossRestart === false && ( + + live only + + )} +
+ ); +} diff --git a/apps/web/src/components/live/AgentControls.tsx b/apps/web/src/components/live/AgentControls.tsx new file mode 100644 index 0000000..b12094d --- /dev/null +++ b/apps/web/src/components/live/AgentControls.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, Check, ShieldQuestion } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { AGENT_LIST, agentLabel } from "@openlive/shared"; +import { api } from "@/lib/api"; +import { useLiveStore } from "@/lib/live/liveStore"; +import { setConversationBind } from "@/lib/live/useLiveSession"; +import type { AgentId } from "@/lib/live/liveClient"; +import { AgentIcon } from "./AgentIcon"; +import { OpenLiveOrb } from "@/components/OpenLiveOrb"; +import { useUi } from "@/lib/uiStore"; +import { usePopIn } from "@/lib/usePopIn"; +import { cn } from "@/lib/cn"; + +// Hydration-safe: false on the server + first client render (SSR markup matches), +// true only after mount inside the Electron app — so the -webkit-app-region class +// never flips during hydration. AgentSelect renders in the SSR'd landing hero. +function useNoDrag(): string { + const [desktop, setDesktop] = useState(false); + useEffect(() => { setDesktop(typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent)); }, []); + return desktop ? "[-webkit-app-region:no-drag]" : ""; +} + +// The built-in assistant + every registry agent, in canonical order. +const OPTIONS: { id: AgentId | null; label: string }[] = [ + { id: null, label: "OpenLive" }, + ...AGENT_LIST.map((a) => ({ id: a.id as AgentId, label: a.label })), +]; + +/** OPTIONS minus agents hidden in Settings — the currently-bound agent stays + * visible even when hidden, so an old conversation still shows what it talks to. */ +function useVisibleOptions(boundAgent: AgentId | null) { + const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: api.settings }); + return OPTIONS.filter((o) => !o.id || o.id === boundAgent || settings?.[`agentHidden:${o.id}`] !== "1"); +} + +export { agentLabel }; + +/** Compact "Talk to" picker for the pre-call screen — choose the agent BEFORE + * starting (styled like the device selects). Same per-conversation bind. */ +export function AgentQuickPick() { + const activeChatId = useUi((s) => s.activeChatId); + const boundAgent = useLiveStore((s) => s.boundAgent); + const options = useVisibleOptions(boundAgent); + return ( + + ); +} + +/** Top-bar selector: what THIS conversation talks to — the built-in assistant or a + * coding agent (Claude Code / Codex / Cursor). Persisted per conversation. */ +export function AgentSelect() { + const activeChatId = useUi((s) => s.activeChatId); + const boundAgent = useLiveStore((s) => s.boundAgent); + const options = useVisibleOptions(boundAgent); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const menuRef = useRef(null); + const noDrag = useNoDrag(); + usePopIn(menuRef, open); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + const current = options.find((o) => o.id === boundAgent) ?? options[0]!; + + return ( +
+ + {open && ( +
+ {options.map((o) => ( + + ))} +
+ )} +
+ ); +} + +/** Seconds until the server's auto-deny, ticking once a second. Null without a + * deadline (older server) or once it has passed. */ +function useCountdown(expiresAt?: number): number | null { + const [left, setLeft] = useState(() => (expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : null)); + useEffect(() => { + if (!expiresAt) { setLeft(null); return; } + const tick = () => setLeft(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [expiresAt]); + return left; +} + +/** Overlay shown when a bound agent asks permission (run a command, edit files). + * The question is also spoken; answer by tapping a chip OR saying yes/no. An + * unanswered ask auto-denies server-side — the countdown makes that visible. */ +export function PermissionPrompt({ answerPermission }: { answerPermission: (optionId: string) => void }) { + const permission = useLiveStore((s) => s.permission); + const left = useCountdown(permission?.expiresAt); + if (!permission) return null; + const mmss = left != null ? `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}` : null; + return ( +
+
+
+ +

{permission.question}

+
+
+ {permission.options.map((o) => ( + + ))} +
+

+ …or just say “yes” or “no”. + {mmss && Auto-deny in {mmss}.} +

+
+
+ ); +} diff --git a/apps/web/src/components/live/AgentIcon.tsx b/apps/web/src/components/live/AgentIcon.tsx new file mode 100644 index 0000000..081ad62 --- /dev/null +++ b/apps/web/src/components/live/AgentIcon.tsx @@ -0,0 +1,47 @@ +import { AGENT_REGISTRY } from "@openlive/shared"; +import type { AgentId } from "@/lib/live/liveClient"; + +// Real brand marks for the coding agents, inlined so they inherit `currentColor` +// (a bundled can't theme to the dark UI). Also bundled as files in +// /public/agents/*.svg. Source: lobehub/icons (Claude, OpenAI, Cursor, OpenCode) +// and Hermes' own ACP registry icon. Brand colors + letter-badge fallbacks (for +// future agents without a mark) come from the shared agent registry. +const PATHS: Partial> = { + // opencode's official block-"O" terminal mark. + "opencode": "M16 6H8v12h8V6zm4 16H4V2h16v20z", + "claude-code": "M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z", + codex: "M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z", + cursor: "M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z", +}; + +export function AgentIcon({ id, className }: { id: AgentId; className?: string }) { + const brand = AGENT_REGISTRY[id]?.brand ?? {}; + const style = brand.color ? { color: brand.color } : undefined; + + // Hermes' official mark (its ACP registry icon) is stroke-based — rendered as + // its own element rather than squeezed into the single-fill-path table. + if (id === "hermes") { + return ( + + ); + } + + const path = PATHS[id]; + return ( + + ); +} diff --git a/apps/web/src/components/live/HintChips.tsx b/apps/web/src/components/live/HintChips.tsx new file mode 100644 index 0000000..5d08289 --- /dev/null +++ b/apps/web/src/components/live/HintChips.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState } from "react"; +import { X, Lightbulb, AlertCircle } from "lucide-react"; +import { useLiveStore } from "@/lib/live/liveStore"; +import { selectHints } from "@/lib/hints"; +import { cn } from "@/lib/cn"; + +// Contextual hint chips above the dock: what you can say/do right now, and +// error recovery with a one-tap fix. At most two, quiet by design. +export function HintChips({ className }: { className?: string }) { + const phase = useLiveStore((s) => s.phase); + const active = useLiveStore((s) => s.active); + const boundAgent = useLiveStore((s) => s.boundAgent); + const agentMeta = useLiveStore((s) => s.agentMeta); + const error = useLiveStore((s) => s.error); + const [dismissed, setDismissed] = useState([]); + + const hints = selectHints({ phase, active, boundAgent, agentMeta, error }).filter((h) => !dismissed.includes(h.id)); + if (!hints.length) return null; + + return ( +
+ {hints.map((h) => ( +
+ {h.id.startsWith("err") ? : } + {h.text} + {h.action && ( + + )} + {h.dismissable && ( + + )} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/live/HoldToSend.tsx b/apps/web/src/components/live/HoldToSend.tsx new file mode 100644 index 0000000..b54fba4 --- /dev/null +++ b/apps/web/src/components/live/HoldToSend.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useLiveStore } from "@/lib/live/liveStore"; +import { loadPipelineConfig } from "@/lib/live/pipelineConfig"; +import { usePopIn } from "@/lib/usePopIn"; +import { cn } from "@/lib/cn"; + +const R = 6.5; +const C = 2 * Math.PI * R; + +/** Presentational "Waiting for you… tap to send" pill: a ring fills toward the + * auto-send moment; tapping commits the turn right away. Also used by the desktop + * mini panel, which gets `until`/`holdMs` over IPC instead of from the store. */ +export function HoldPill({ until, holdMs, onSend, compact }: { until: number; holdMs: number; onSend: () => void; compact?: boolean }) { + const [frac, setFrac] = useState(0); + const ref = useRef(null); + usePopIn(ref, true); // pops on mount (the pill appears the moment a hold starts) + + useEffect(() => { + let raf = 0; + const tick = () => { + const remaining = until - Date.now(); + setFrac(Math.min(1, Math.max(0, 1 - remaining / holdMs))); + if (remaining > 0) raf = requestAnimationFrame(tick); + }; + tick(); + return () => cancelAnimationFrame(raf); + }, [until, holdMs]); + + return ( + + ); +} + +/** Store-connected wrapper — visible while the engine holds a mid-thought pause + * (Smart-Turn said "not done" / the words trailed off). */ +export function HoldToSend({ sendNow, compact }: { sendNow: () => void; compact?: boolean }) { + const holdUntil = useLiveStore((s) => s.holdUntil); + if (!holdUntil) return null; + return ; +} diff --git a/apps/web/src/components/live/InCall.tsx b/apps/web/src/components/live/InCall.tsx index 90ae75a..4076783 100644 --- a/apps/web/src/components/live/InCall.tsx +++ b/apps/web/src/components/live/InCall.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { useReducedMotion } from "motion/react"; -import { Mic, MicOff, Video, VideoOff, ScreenShare, ScreenShareOff, ChevronUp, Minimize2, PanelRightOpen } from "lucide-react"; +import { Mic, MicOff, Video, VideoOff, ScreenShare, ScreenShareOff, ChevronUp, Minimize2, PanelRightOpen, Keyboard } from "lucide-react"; +import { gsap, useGSAP, DUR, EASE, prefersReduced } from "@/lib/gsap"; import { useLiveStore, type LivePhase, type DeviceOpt } from "@/lib/live/liveStore"; import { toolMeta } from "@/lib/live/toolMeta"; import { useUi } from "@/lib/uiStore"; @@ -10,8 +10,13 @@ import { Orb } from "./Orb"; import { CameraPiP } from "./CameraPiP"; import { ScreenTile } from "./ScreenTile"; import { EndCallButton } from "./EndCallButton"; +import { HoldToSend } from "./HoldToSend"; +import { HintChips } from "./HintChips"; import { TranscriptPanel } from "./TranscriptPanel"; import { TopBar } from "./TopBar"; +import { setPttEnabled } from "@/lib/live/usePtt"; +import { SpotlightTour } from "@/components/SpotlightTour"; +import { usePopIn } from "@/lib/usePopIn"; import { cn } from "@/lib/cn"; const PHASE_LABEL: Record = { @@ -28,16 +33,34 @@ export interface InCallProps { getLevels: () => { mic: number; agent: number }; getBands: () => { mic: number[]; agent: number[] }; onEnd: () => void; + sendNow: () => void; + pttUp: () => void; } export function InCall(props: InCallProps) { const { chatId, phase, muted, cameraOn, screenOn, cameraStream, screenStream, error, - toggleMute, toggleCamera, toggleScreen, setMic, setCam, getLevels, getBands, onEnd } = props; - const { userCaption, userPartial, agentCaption, agentCaptionMs, toolStatus, warming, mics, cams, micId, camId } = useLiveStore(); + toggleMute, toggleCamera, toggleScreen, setMic, setCam, getLevels, getBands, onEnd, sendNow, pttUp } = props; + const { userCaption, userPartial, agentCaption, agentCaptionMs, toolStatus, warming, pttActive, pttEnabled, mics, cams, micId, camId } = useLiveStore(); + // Arm/disarm push-to-talk. Disarming while Space is held first ends the hold + // cleanly (the engine owns the held audio), then drops the armed flag. + const togglePtt = () => { if (pttEnabled && pttActive) pttUp(); setPttEnabled(!pttEnabled); }; const setMinimized = useUi((s) => s.setMinimized); - const reduce = useReducedMotion(); + const root = useRef(null); const sharing = cameraOn || screenOn; // orb shrinks into the bar while a visual source is on + // Entrance — a gentle rise + settle when the call becomes active (skipped for + // reduced-motion, which leaves the element at its final state). + const { contextSafe } = useGSAP(() => { + if (prefersReduced()) return; + gsap.fromTo(root.current, { autoAlpha: 0, y: 8, scale: 0.985 }, { autoAlpha: 1, y: 0, scale: 1, duration: DUR.enter, ease: EASE.snappy }); + }, { scope: root }); + + // Exit — a quick settle-down before teardown so ending never feels like a cut. + const handleEnd = contextSafe(() => { + if (!root.current || prefersReduced()) { onEnd(); return; } + gsap.to(root.current, { autoAlpha: 0, y: 6, scale: 0.99, duration: DUR.fast, ease: EASE.soft, onComplete: onEnd }); + }); + // Transcript sidebar: resizable width + open/closed, both remembered. const [panelOpen, setPanelOpen] = useState(() => (typeof window === "undefined" ? true : localStorage.getItem("ol-transcript-open") !== "0")); const [panelW, setPanelW] = useState(() => { @@ -74,12 +97,40 @@ export function InCall(props: InCallProps) { : null; // Status line: a live tool cue while a tool runs, "Warming up…" right after - // connecting (both blue shimmer), otherwise the plain phase label. - const statusLabel = toolStatus ? `${toolMeta(toolStatus).active}…` : warming ? "Warming up…" : PHASE_LABEL[phase]; + // connecting (both blue shimmer), push-to-talk while held, otherwise the phase label. + const statusLabel = pttActive ? "Push-to-talk — release to send" : toolStatus ? `${toolMeta(toolStatus).active}…` : warming ? "Warming up…" : PHASE_LABEL[phase]; const statusBusy = !!toolStatus || warming; + // In-call keyboard shortcuts. Single letters are safe here — there's no text + // input during a call (Space/Enter already belong to push-to-talk/hold-commit). + // Skipped when a menu input or modifier is involved, except ⌘E (end). + const [sheetOpen, setSheetOpen] = useState(false); + const toggleHistory = useUi((s) => s.toggleHistory); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "e") { e.preventDefault(); handleEnd(); return; } + if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return; + switch (e.key) { + case "m": case "M": toggleMute(); break; + case "c": case "C": void toggleCamera(); break; + case "s": case "S": void toggleScreen(); break; + case "t": case "T": setPanelOpen((v) => !v); break; + case "h": case "H": toggleHistory(); break; + case "?": setSheetOpen((v) => !v); break; + case "Escape": setSheetOpen(false); return; // don't preventDefault other Esc handlers + default: return; + } + e.preventDefault(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toggleMute, toggleCamera, toggleScreen, toggleHistory]); + return ( -
+
@@ -90,6 +141,7 @@ export function InCall(props: InCallProps) {

{words}

{statusLabel}

+
)} @@ -99,7 +151,7 @@ export function InCall(props: InCallProps) { {error &&

{error}

} {!panelOpen && ( - @@ -108,16 +160,21 @@ export function InCall(props: InCallProps) { {/* Status pill (orb + caption) while sharing — floats ABOVE the control bar so toggling a screen/camera share never resizes the bar itself. */} {sharing && ( -
+
{words ?? {statusLabel}} +
)} + {/* contextual hints — above the control bar, quiet, at most two chips */} + + {/* control bar — a stable width regardless of sharing */} -
+
+ void toggleCamera()} @@ -125,13 +182,51 @@ export function InCall(props: InCallProps) { void toggleScreen()} icon={screenOn ? ScreenShareOff : ScreenShare} /> setMinimized(true)} icon={Minimize2} /> - +
{/* transcript sidebar — resizable + collapsible */} {panelOpen && setPanelOpen(false)} />}
+ + + + {sheetOpen && setSheetOpen(false)} />} +
+ ); +} + +// The "?" cheat sheet — every in-call binding in one quiet card. +function ShortcutSheet({ pttEnabled, onClose }: { pttEnabled: boolean; onClose: () => void }) { + const mac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform); + const mod = mac ? "⌘" : "Ctrl"; + const rows: [string, string][] = [ + ["M", "Mute / unmute"], + ["C", "Camera on / off"], + ["S", "Share screen"], + ["T", "Show / hide activity panel"], + ["H", "History sidebar"], + [`${mod} E`, "End call"], + [`${mod} ,`, "Settings"], + ...(pttEnabled ? [["Space", "Push-to-talk (hold or tap)"], ["Enter", "Send a held thought now"]] as [string, string][] : []), + ["?", "This cheat sheet"], + ]; + return ( +
+
e.stopPropagation()}> +

Keyboard shortcuts

+
+ {rows.map(([key, what]) => ( +
+ {what} + {key} +
+ ))} +
+
); } @@ -152,6 +247,8 @@ function ControlWithMenu({ on, icon, title, onClick, danger, devices, activeId, }) { const [open, setOpen] = useState(false); const ref = useRef(null); + const menuRef = useRef(null); + usePopIn(menuRef, open); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; @@ -168,7 +265,7 @@ function ControlWithMenu({ on, icon, title, onClick, danger, devices, activeId, )} {open && ( -
+
{label}
{devices.map((d) => ( -
- void setMic(id)} onCam={setCam} - error={error} modelsDownloaded={modelsDownloaded} downloading={downloading} downloadPct={downloadPct} - downloadLoaded={downloadLoaded} downloadTotal={downloadTotal} downloadModels={downloadModels} - refreshDevices={refreshDevices} onDownload={() => void download()} onStart={() => void start()} - onOpenSettings={openSettings} /> - - - )} - + {!active && ( + void setMic(id)} onCam={setCam} + error={error} modelsDownloaded={modelsDownloaded} downloading={downloading} downloadPct={downloadPct} + downloadLoaded={downloadLoaded} downloadTotal={downloadTotal} downloadModels={downloadModels} + refreshDevices={refreshDevices} onDownload={() => void download()} onStart={() => void start()} + onOpenSettings={openSettings} onExit={end} /> + )} - {active && minimized && ( + {/* Desktop mini = a separate always-on-top panel window (this window hides); + web mini = the in-page pill overlay. */} + {active && minimized && isDesktop && ( + + )} + {active && minimized && !isDesktop && ( + getLevels={getLevels} getBands={getBands} onEnd={end} sendNow={sendNow} /> )} {active && !minimized && ( - void setMic(id)} setCam={setCam} - getLevels={getLevels} getBands={getBands} onEnd={end} /> + getLevels={getLevels} getBands={getBands} onEnd={end} sendNow={sendNow} /> )} + {active && } ); } diff --git a/apps/web/src/components/live/LiveStage.tsx b/apps/web/src/components/live/LiveStage.tsx index 8c965b7..d2fc46c 100644 --- a/apps/web/src/components/live/LiveStage.tsx +++ b/apps/web/src/components/live/LiveStage.tsx @@ -4,18 +4,16 @@ import { useEffect, useRef, useState } from "react"; import { Mic, Video, VideoOff } from "lucide-react"; import type { DeviceOpt } from "@/lib/live/liveStore"; import type { ModelProgress } from "@/lib/live/models"; -import { hasWebGPU } from "@/lib/live/models"; -import { ModelQuickPick } from "./ModelQuickPick"; import { cn } from "@/lib/cn"; -// The pre-call screen for Live voice: device pickers, a self-preview + mic meter, -// and the on-device model download. The in-call UI lives in LiveDock/VoiceBar. +// Reusable pre-call building blocks: device pickers, a self-preview + mic meter, +// and the on-device model download. Composed by the full-page Lobby. const mb = (bytes: number) => (bytes / 1_048_576).toFixed(bytes >= 100 * 1_048_576 ? 0 : 1); const MODEL_ROLE: Record = { stt: "hears you", tts: "speaks back", turn: "knows when you're done" }; // Shared, transparent progress: overall bar + real MB, and a per-model checklist. -function DownloadProgress({ pct, loaded, total, models }: { pct: number; loaded: number; total: number; models: ModelProgress[] }) { +export function DownloadProgress({ pct, loaded, total, models }: { pct: number; loaded: number; total: number; models: ModelProgress[] }) { return (
@@ -43,7 +41,7 @@ function DownloadProgress({ pct, loaded, total, models }: { pct: number; loaded: ); } -function DeviceSelect({ icon: Icon, opts, value, onChange }: { icon: typeof Mic; opts: DeviceOpt[]; value?: string; onChange: (id: string) => void }) { +export function DeviceSelect({ icon: Icon, opts, value, onChange }: { icon: typeof Mic; opts: DeviceOpt[]; value?: string; onChange: (id: string) => void }) { if (!opts.length) return

No device found

; return (