mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge branch 'dev' into feat/out-of-credits-warning
This commit is contained in:
commit
c75d55ed31
224 changed files with 32863 additions and 2702 deletions
15
.github/workflows/electron-build.yml
vendored
15
.github/workflows/electron-build.yml
vendored
|
|
@ -163,12 +163,25 @@ jobs:
|
|||
run: pnpm install --frozen-lockfile
|
||||
working-directory: apps/x
|
||||
|
||||
- name: Build node-pty native binary for Linux
|
||||
working-directory: apps/x
|
||||
run: |
|
||||
# node-pty ships prebuilt binaries only for darwin/win32; compile the
|
||||
# linux-x64 binary so bundle.mjs can stage it into the package. Without
|
||||
# this the Linux app crashes on launch (missing prebuilds/linux-x64/pty.node).
|
||||
PTY="node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty"
|
||||
cd "$PTY"
|
||||
npx node-gyp rebuild
|
||||
mkdir -p prebuilds/linux-x64
|
||||
cp build/Release/pty.node prebuilds/linux-x64/
|
||||
|
||||
- name: Build electron app
|
||||
env:
|
||||
VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}
|
||||
VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-forge publish --arch=x64,arm64 --platform=linux
|
||||
ROWBOAT_SKIP_PACMAN: '1' # Arch Linux package is built locally only, never in CI
|
||||
run: npx electron-forge publish --arch=x64 --platform=linux
|
||||
working-directory: apps/x/apps/main
|
||||
|
||||
- name: Upload workflow artifacts
|
||||
|
|
|
|||
40
.github/workflows/x-tests.yml
vendored
Normal file
40
.github/workflows/x-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: apps/x tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/x/**"
|
||||
- ".github/workflows/x-tests.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/x/**"
|
||||
- ".github/workflows/x-tests.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/x
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: apps/x/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Builds @x/shared (core/renderer tests import its dist), then runs
|
||||
# the shared, core, and renderer vitest suites in order.
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,3 +4,6 @@
|
|||
data/
|
||||
.venv/
|
||||
.claude/
|
||||
|
||||
# Local-only meeting-prep planning doc
|
||||
/PLAN.md
|
||||
|
|
|
|||
101
AGENTS.md
Normal file
101
AGENTS.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# AGENTS.md — Working on the Rowboat runtime
|
||||
|
||||
Context for AI coding agents (and humans) working on this repo. General
|
||||
codebase orientation lives in `CLAUDE.md`; this file covers the **new
|
||||
turn/session runtime** in `apps/x` — its storage, its debugging tools, and
|
||||
the invariants you must not break.
|
||||
|
||||
## The runtime in one paragraph
|
||||
|
||||
Chats are **sessions**; each user message starts a **turn**. Both are
|
||||
append-only JSONL event logs under `~/.rowboat/storage/{turns,sessions}/YYYY/MM/DD/`.
|
||||
All state is derived by pure reducers (`reduceTurn`, `reduceSession` in
|
||||
`@x/shared/src/turns.ts` / `sessions.ts`) shared byte-for-byte between the
|
||||
main process and the renderer. Design specs:
|
||||
`apps/x/packages/core/docs/turn-runtime-design.md` and `session-design.md` —
|
||||
read the relevant spec before changing runtime behavior.
|
||||
|
||||
## Storage is reference-based — files store each fact once
|
||||
|
||||
Three applications of the same mechanism:
|
||||
|
||||
1. **Context**: a session turn's `context` is `{ previousTurnId }`; the
|
||||
conversation prefix is materialized by walking the chain
|
||||
(`TurnRepoContextResolver`).
|
||||
2. **Model requests**: `model_call_requested.request.messages` is a list of
|
||||
string refs into the turn's own events — `"context"`, `"input"`,
|
||||
`"assistant:<index>"`, `"toolResult:<toolCallId>"` — recording only what
|
||||
is NEW since the previous call.
|
||||
3. **Agent snapshots**: when a turn's system prompt + tools are
|
||||
byte-identical to its predecessor's, `turn_created.agent.resolved` is
|
||||
`{ agentId, model, inheritedFrom }` instead of re-persisting ~70KB. The
|
||||
model stays concrete (mid-session model switches still inherit).
|
||||
|
||||
The exact provider payload is rebuilt by `composeModelRequest`
|
||||
(`packages/core/src/turns/compose-model-request.ts`) — **the same code path
|
||||
the loop transmits through**, so the file plus the composer reproduce the
|
||||
wire bytes exactly (there is a property test asserting composed == sent).
|
||||
|
||||
## Inspecting turns and sessions
|
||||
|
||||
```bash
|
||||
cd apps/x/packages/core
|
||||
|
||||
# A whole session: title, per-turn status/size/input preview
|
||||
npm run inspect -- <sessionId | path/to/session.jsonl>
|
||||
|
||||
# One turn: per model call, the EXACT provider payload — resolved system
|
||||
# prompt, tool list, wire-form messages (user-message context woven in,
|
||||
# tool-result envelopes), and the response/failure
|
||||
npm run inspect -- <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]
|
||||
|
||||
# Cascade full turn inspection across a session
|
||||
npm run inspect -- <sessionId> --turns
|
||||
```
|
||||
|
||||
`--full` prints untruncated system prompts and message contents. Turn vs
|
||||
session ids are auto-detected. This is the intended way to see "what did the
|
||||
model actually receive" — the raw JSONL deliberately stores structural facts
|
||||
and references, never the derived wire form.
|
||||
|
||||
## The legacy runs runtime is code-mode-only
|
||||
|
||||
The old runs runtime (`packages/core/src/runs/`, `AgentRuntime`/`streamAgent`
|
||||
in `packages/core/src/agents/runtime.ts`, the `runs:*` IPC channels, and the
|
||||
renderer's legacy chat-tab state machine in `App.tsx`) remains in the tree
|
||||
**solely for code-mode sessions** — a deliberate, documented carve-out:
|
||||
|
||||
- A code session IS a run (`sessionId === runId`). Direct-mode prompts drive
|
||||
an external ACP agent and hand-assemble `code-run-event`s into the run log
|
||||
(`code-mode/sessions/service.ts`); rowboat-mode code tabs go through
|
||||
`runs:createMessage` → the old `AgentRuntime` loop.
|
||||
- Everything else — main chat, background tasks, live notes, knowledge
|
||||
pipelines, scheduled agents — runs on turns/sessions. Do NOT add new
|
||||
callers of `createRun`/`createMessage`/`streamAgent`; headless work uses
|
||||
`packages/core/src/agents/headless.ts` (`startHeadlessAgent`/
|
||||
`runHeadlessAgent`).
|
||||
- Temporary bridges that die when code-mode is unified: the renderer
|
||||
transcript loader's `runs:fetch` fallback (`lib/agent-transcript.ts`), the
|
||||
`runs:downloadLog` fallback in the chat sidebar, and the `notify-user`
|
||||
gate's `fetchRun` fallback (`application/lib/builtin-tools.ts`).
|
||||
- The remaining migration (designed but deferred): port code sessions onto
|
||||
sessions/turns — rowboat-mode prompts become normal turns with
|
||||
`codeMode`/`codeCwd` composition (already supported by the agent
|
||||
resolver's composition overrides), direct-mode prompts become a delegated
|
||||
turn kind carrying opaque code events. That project deletes `runs/`
|
||||
entirely.
|
||||
|
||||
## Invariants to respect
|
||||
|
||||
- Turn/session files are **append-only**; reducers reject impossible
|
||||
histories loudly (`TurnCorruptionError`). Never hand-edit files.
|
||||
- Durable events are persisted **before** side effects (model calls, tool
|
||||
invocations). Deltas (`text_delta`, …) are stream-only, never persisted.
|
||||
- The reducers in `@x/shared` must stay pure (no I/O, no node imports) —
|
||||
the renderer imports them directly.
|
||||
- Every behavior change needs tests: reducers in `packages/shared`,
|
||||
runtime/sessions in `packages/core`, renderer stores/views in
|
||||
`apps/renderer` (all vitest; run `npm test` per package).
|
||||
- Schema changes: the schema is pre-release (`schemaVersion: 1` throughout);
|
||||
breaking changes are acceptable but require wiping `~/.rowboat/storage`
|
||||
and a note in the commit message.
|
||||
|
|
@ -109,7 +109,9 @@ Long-form docs for specific features. Read the relevant file before making chang
|
|||
| Feature | Doc |
|
||||
|---------|-----|
|
||||
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
|
||||
| Calls (video mode) — one hands-free call engine with four presets (voice / video / share screen / practice coaching), device-derived surfaces (full-screen ⇄ floating popout), frame pipeline, prompts catalog | `apps/x/VIDEO_MODE.md` |
|
||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
||||
| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
|
|||
135
README.md
135
README.md
|
|
@ -1,9 +1,8 @@
|
|||
<a href="https://www.youtube.com/watch?v=5AWoGo-L16I" target="_blank" rel="noopener noreferrer">
|
||||
<img width="1339" height="607" alt="rowboat-github-2" src="https://github.com/user-attachments/assets/fc463b99-01b3-401c-b4a4-044dad480901" />
|
||||
</a>
|
||||
|
||||
<h5 align="center">
|
||||
|
||||
<h1 align="center">Rowboat</h1>
|
||||
<p align="center">A desktop AI coworker with a memory of your work and built-in surfaces to act on it.</p>
|
||||
|
||||
<p align="center" style="display: flex; justify-content: center; gap: 20px; align-items: center;">
|
||||
<a href="https://trendshift.io/repositories/13609" target="blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/13609" alt="rowboatlabs/rowboat | Trendshift" width="250" height="55"/>
|
||||
|
|
@ -25,28 +24,95 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
# Rowboat
|
||||
**Open-source AI coworker that turns work into a knowledge graph and acts on it**
|
||||
|
||||
</h5>
|
||||
|
||||
Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine.
|
||||
Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects.
|
||||
|
||||
You can do things like:
|
||||
- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
|
||||
- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
|
||||
- Track a person, company or topic through live notes
|
||||
- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown)
|
||||
- Record voice memos that automatically capture and update key takeaways in the graph
|
||||
|
||||
Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
|
||||
|
||||
<p align="center">
|
||||
<img width="1502" height="938" alt="Screenshot 2026-06-24 at 11 40 45 PM" src="https://github.com/user-attachments/assets/d84cbdf2-42a6-4767-9dec-81cfa435f310" />
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://youtu.be/NcWGdwQ7Cpo"> Demo - email to code</a> · <a href="https://www.youtube.com/watch?v=7xTpciZCfpw"> Demo - knowledge graph</a>
|
||||
</p>
|
||||
|
||||
|
||||
⭐ If you find Rowboat useful, please star the repo. It helps more people find it.
|
||||
|
||||
## Demo
|
||||
[](https://www.youtube.com/watch?v=7xTpciZCfpw)
|
||||
---
|
||||
## Overview
|
||||
|
||||
[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Brain</h3>
|
||||
Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1499" height="940" alt="Screenshot 2026-06-24 at 11 22 52 PM" src="https://github.com/user-attachments/assets/203ca1d5-2f40-43dd-8da7-c5cef4e31a41" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Email</h3>
|
||||
The built-in email client sorts emails into important and everything else. Rowboat automatically drafts responses for important email using all the work context.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="948" alt="Email screenshot" src="https://github.com/user-attachments/assets/4392b3ca-cc4c-473a-849a-eea0e97388f2" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Background agents</h3>
|
||||
You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="951" alt="Background agents screenshot" src="https://github.com/user-attachments/assets/5b73a3c3-f0d3-4151-83e7-0997457074e6" />
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Built-in Browser</h3>
|
||||
Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="948" alt="Browser screenshot" src="https://github.com/user-attachments/assets/ce04871f-4477-40eb-8310-13ef7f125b11" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Meeting Notes</h3>
|
||||
A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="947" alt="Meeting notes screenshot" src="https://github.com/user-attachments/assets/c3729952-3c75-4c84-88e0-2a9070136502" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Code Mode</h3>
|
||||
Code mode lets you spin up parallel coding agents with Claude Code or Codex, and have Rowboat drive them with all the work context where needed.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="949" alt="Code mode screenshot" src="https://github.com/user-attachments/assets/306618b5-9aaf-4ef8-9117-91ea58e5e4e7" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Integrations</h3>
|
||||
Includes one-click integrations to most popular products.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="948" alt="Integrations screenshot" src="https://github.com/user-attachments/assets/402e89db-8229-468a-8881-a763b9f20ad9" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -81,23 +147,6 @@ All API key files use the same format:
|
|||
}
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
Rowboat is a **local-first AI coworker** that can:
|
||||
- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments)
|
||||
- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc)
|
||||
- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides)
|
||||
|
||||
Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit.
|
||||
|
||||
## Integrations
|
||||
|
||||
Rowboat builds memory from the work you already do, including:
|
||||
- **Gmail** (email)
|
||||
- **Google Calendar**
|
||||
- **Rowboat meeting notes** or **Fireflies**
|
||||
|
||||
It also contains a library of product integrations through Composio.dev
|
||||
|
||||
## How it’s different
|
||||
|
||||
|
|
@ -111,24 +160,6 @@ Rowboat maintains **long-lived knowledge** instead:
|
|||
|
||||
The result is memory that compounds, rather than retrieval that starts cold every time.
|
||||
|
||||
## What you can do with it
|
||||
|
||||
- **Meeting prep** from prior decisions, threads, and open questions
|
||||
- **Email drafting** grounded in history and commitments
|
||||
- **Docs & decks** generated from your ongoing context (including PDF slides)
|
||||
- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
|
||||
- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
|
||||
|
||||
## Live notes
|
||||
|
||||
Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note.
|
||||
|
||||
- Track a competitor or market topic across X, Reddit, and the news
|
||||
- Monitor a person, project, or deal across web or your communications
|
||||
- Keep a running summary of any subject you care about
|
||||
|
||||
Everything is written back into your local Markdown vault. You control what runs and when.
|
||||
|
||||
## Bring your own model
|
||||
|
||||
Rowboat works with the model setup you prefer:
|
||||
|
|
|
|||
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
BIN
apps/x/.pnpm-store/v11/index.db
Normal file
BIN
apps/x/.pnpm-store/v11/index.db
Normal file
Binary file not shown.
|
|
@ -92,6 +92,8 @@ All in `apps/renderer/src/lib/analytics.ts`:
|
|||
- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }`
|
||||
- `oauth_connected` / `oauth_disconnected` — `{ provider }`
|
||||
- `voice_input_started` — no properties
|
||||
- `call_started` — `{ preset: 'voice' | 'video' | 'share' | 'practice' }` — a hands-free call began (see `apps/x/VIDEO_MODE.md`)
|
||||
- `call_turn_latency` — `{ endpoint_to_submit_ms, submit_to_speak_ms, speak_to_audio_ms, total_ms }` — voice-to-voice latency breakdown for one call turn (utterance accepted → submitted → first TTS speak → audio playing)
|
||||
- `search_executed` — `{ types: string[] }`
|
||||
- `note_exported` — `{ format }`
|
||||
|
||||
|
|
|
|||
253
apps/x/VIDEO_MODE.md
Normal file
253
apps/x/VIDEO_MODE.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Calls (Video Mode) — Deep Dive
|
||||
|
||||
Calls let the user talk to the assistant hands-free while it *sees* them
|
||||
(webcam) and their screen (screen share). There is ONE call engine —
|
||||
continuous listening, auto-submitted utterances, forced read-aloud TTS, frame
|
||||
capture — entered through four presets that differ only in starting devices.
|
||||
This doc covers the product flow, the technical pipeline, and the LLM prompt
|
||||
surface with exact pointers.
|
||||
|
||||
## Product flow
|
||||
|
||||
The composer has a **call split-button** (`chat-input-with-mentions.tsx`).
|
||||
The main click is the "work together" default — preset `share`: screen
|
||||
sharing ON, camera OFF, floating pill, so the user keeps working while the
|
||||
assistant watches along (the button tooltip discloses the screen share). The
|
||||
chevron menu holds the deviations. While a call is live the button turns red
|
||||
and ends it.
|
||||
|
||||
| Preset | Starting devices | First surface |
|
||||
|--------|------------------|---------------|
|
||||
| `share` — main click | screen on, camera off | floating pill |
|
||||
| `voice` — "Voice call" | camera off, screen off | floating mascot pill |
|
||||
| `video` — "Video call" | camera on | full-screen call |
|
||||
| `practice` — "Practice session" | camera on, + coaching persona | full-screen call |
|
||||
|
||||
**One surface rule** (`callSurface` in `App.tsx`): full screen and screen
|
||||
sharing are mutually exclusive in both directions — a full-screen call covers
|
||||
the screen, so sharing it would show the call itself.
|
||||
|
||||
- sharing → floating popout, always (pill = working)
|
||||
- not sharing → full screen unless `callMinimized` (full screen = facing
|
||||
each other)
|
||||
- expanding the pill auto-STOPS any share; minimizing the full-screen call
|
||||
auto-STARTS one (the pill exists to work together) — presenting from full
|
||||
screen likewise collapses to the pill
|
||||
- the camera toggle never changes the surface: turning it on from the pill
|
||||
puts your video IN the pill; expanding is its own explicit action
|
||||
|
||||
**Screen-share consent** is three-layered: a toast the moment any share
|
||||
starts ("Your screen is being shared… [Stop sharing]"), a persistent
|
||||
"Sharing screen" badge on the pill, and macOS's purple recording indicator.
|
||||
If the auto-share fails (Screen Recording permission not granted) the call
|
||||
starts anyway as a voice call, with a toast linking to System Settings.
|
||||
Practice/coaching is always an explicit choice — expanding to full screen
|
||||
never turns the coach on.
|
||||
|
||||
In-call controls (identical bar on both surfaces): mic mute, camera toggle
|
||||
(silhouette avatar while off, no webcam frames captured), screen share
|
||||
toggle, mascot ⇄ "R" letter avatar, end call. **Mute is a full input
|
||||
pause**, not just audio — mic audio stops reaching Deepgram
|
||||
(`useVoiceMode.setPaused`, OR'd with the automatic thinking/speaking pause)
|
||||
AND camera/screen frame capture stops (`useVideoMode.setCapturePaused`;
|
||||
`collectFrames()` returns nothing while muted, so typed messages carry no
|
||||
frames either), letting the user talk to someone in the room without the
|
||||
assistant listening in. Devices stay acquired for instant unmute (camera
|
||||
light and macOS share indicator stay on — the pill's share badge switches to
|
||||
"Sharing paused"), the status chip shows "Muted" instead of "Listening",
|
||||
and assistant output is unaffected (in-flight speech keeps playing; Stop
|
||||
handles that). Mute resets to off at call start/end. While the assistant is thinking or speaking, a
|
||||
red **Stop** button appears on the mascot tile — it silences TTS instantly,
|
||||
skips queued voice segments, and aborts the run if it's still generating
|
||||
(stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the
|
||||
assistant's spoken line run along the bottom. Typing in the composer still
|
||||
works mid-call; frames ride along with typed messages too.
|
||||
|
||||
Outside calls the composer keeps exactly one voice affordance: the **mic
|
||||
button** (push-to-talk dictation, untouched). Spoken responses exist only
|
||||
inside calls (forced full read-aloud, off on hang-up). The old video
|
||||
dropdown, talking-head toggle, read-aloud headphones toggle, and summary/full
|
||||
TTS dropdown are all retired — a per-message "read aloud" action on assistant
|
||||
messages is the planned replacement for text-in/voice-out.
|
||||
|
||||
The call button is disabled unless both voice input (Deepgram) and voice
|
||||
output (TTS) are configured. `call_started` (with `preset`) is captured in
|
||||
PostHog — the adoption metric for this feature.
|
||||
|
||||
**Popout mechanics**: a small always-on-top frameless window (camera tile
|
||||
when on + mascot tile, live caption, control bar) floating over every app —
|
||||
including Rowboat. Control-bar actions round-trip `video:popoutAction` →
|
||||
main → `video:popout-action` → app window, which owns the mic/camera/capture;
|
||||
`expand` also refocuses the app window (handled in main).
|
||||
|
||||
## Frame pipeline
|
||||
|
||||
`apps/renderer/src/hooks/useVideoMode.ts` runs one capture pipe per source
|
||||
(stream → offscreen `<video>` → canvas JPEG → ring buffer):
|
||||
|
||||
- Cadence: 1 fps (`CAPTURE_INTERVAL_MS`, line 20); ring buffer ~2 min.
|
||||
- Webcam: 512px wide, JPEG q0.65, max **12 frames/message** (lines 21, 31).
|
||||
- Screen: 1280px wide (text legibility), JPEG q0.7, max **4 frames/message**
|
||||
(lines 24, 32).
|
||||
- `collectFrames()` drains frames buffered since the last send, evenly
|
||||
sampled down to the caps, always keeping the newest; grabs one final frame
|
||||
at the moment of send. Falls back to the single latest frame for
|
||||
rapid-fire messages.
|
||||
|
||||
`App.tsx` `handlePromptSubmit` attaches the drained frames (whenever a call
|
||||
is live) to the outgoing message as `UserImagePart`s and sets
|
||||
`composition.videoMode` when the camera or screen is active, plus
|
||||
`composition.coachMode` during a practice session. Frames also become
|
||||
`isVideoFrame` display attachments (filmstrip in the transcript —
|
||||
`chat-message-attachments.tsx`; history hydration in
|
||||
`lib/run-to-conversation.ts`).
|
||||
|
||||
## Message schema & model encoding
|
||||
|
||||
- `packages/shared/src/message.ts:51` — `UserImagePart`: inline base64
|
||||
(`data`, `mediaType`), `source: 'camera' | 'screen'`, `capturedAt`. Unlike
|
||||
file attachments (path references read via the `LLMParse` tool), image
|
||||
parts go to the model as real multimodal image parts.
|
||||
- `packages/core/src/agents/runtime.ts` `convertFromMessages` (~line 1013):
|
||||
emits a context line (frame counts + time span), then labeled groups —
|
||||
a `"Webcam frames (oldest to newest):"` text part before camera images and
|
||||
a `"Screen-share frames (oldest to newest):"` text part before screen
|
||||
images — so the model never confuses the user with their screen.
|
||||
- Frames stay inline in history (no pruning) deliberately: pruning would
|
||||
bust provider prefix caching every turn and cost more than it saves.
|
||||
- The auto-permission classifier stringifies + truncates content to ~3KB per
|
||||
message, so inline base64 can't blow up its prompt.
|
||||
|
||||
## Hands-free voice loop
|
||||
|
||||
`apps/renderer/src/hooks/useVoiceMode.ts`:
|
||||
|
||||
- `startContinuous(onUtterance)` (line 404): push-to-talk params but with
|
||||
`endpointing=1800` (line 25) so thinking pauses don't cut the user off,
|
||||
plus `utterance_end_ms=2000` (line 38) as a second end-of-speech signal.
|
||||
**Gotcha:** Deepgram's `speech_final` usually arrives on a result with an
|
||||
EMPTY transcript — empty finals must reach the endpoint check or
|
||||
utterances never complete (see the NOTE in `ws.onmessage`).
|
||||
- `setPaused(true)` (line 414) while the assistant thinks/speaks: drops mic
|
||||
audio (so TTS is never transcribed back), discards half-heard buffer,
|
||||
sends Deepgram KeepAlives every 5s. `App.tsx` drives this from
|
||||
`activeIsProcessing || tts.state !== 'idle'`.
|
||||
- Mid-call socket drops reconnect after 1s; the offline audio backlog is
|
||||
capped (~30s).
|
||||
|
||||
Call lifecycle lives in `App.tsx` `startCall(preset)` / `endCall()`:
|
||||
entering a call saves/forces TTS settings, cancels any push-to-talk
|
||||
recording, and starts the continuous loop; ending restores everything.
|
||||
Push-to-talk is disabled while a call owns the mic.
|
||||
|
||||
## Popout window
|
||||
|
||||
- The popout window keeps the Dock icon alive: it uses
|
||||
`setVisibleOnAllWorkspaces(true)` WITHOUT `visibleOnFullScreen` — that flag
|
||||
turns the app into a macOS "agent" app and hides its Dock icon while the
|
||||
window exists (looks like Rowboat vanished). Trade-off: the popout doesn't
|
||||
hover over other apps' fullscreen Spaces.
|
||||
- Shown iff the derived `callSurface === 'popout'` (effect in `App.tsx`).
|
||||
Renderer asks `video:setPopout {show}`; main creates a frameless,
|
||||
`alwaysOnTop` ('floating'), all-workspaces BrowserWindow at the top-right
|
||||
of the primary display, loading the renderer bundle with `#video-popout`
|
||||
(`apps/renderer/src/main.tsx` branches on the hash →
|
||||
`components/video-popout.tsx`).
|
||||
- Call state streams over the `video:popout-state` push channel; main caches
|
||||
the last payload and replays it on popout load. Shown with
|
||||
`showInactive()` so it never steals focus.
|
||||
- The popout captures its **own** camera preview (MediaStreams can't cross
|
||||
windows) and synthesizes the mascot mouth level (no audio in that window).
|
||||
- `video:popoutAction` relays control-bar actions to the app window, matched
|
||||
only by real app-window URLs — `getAllWindows()` also contains hidden
|
||||
utility windows (PDF export) that must not be shown or messaged.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Camera: `voice:ensureCameraAccess` settles the macOS TCC prompt before
|
||||
`getUserMedia` (same pattern as the mic). `NSCameraUsageDescription` is in
|
||||
`forge.config.cjs` `extendInfo`.
|
||||
- Screen: `getDisplayMedia` is auto-approved with the primary screen by
|
||||
`setDisplayMediaRequestHandler` in `main.ts` (no picker);
|
||||
`meeting:checkScreenPermission` registers the app in macOS Screen
|
||||
Recording settings on first use.
|
||||
|
||||
## LLM prompts catalog
|
||||
|
||||
| Prompt | Where |
|
||||
|--------|-------|
|
||||
| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/agents/runtime.ts:386` (`composeSystemInstructions`, gated on `videoMode`) |
|
||||
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `composeSystemInstructions`, gated on `coachMode` (directly after the video section) |
|
||||
| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`application/assistant/skills/app-navigation/skill.ts`) |
|
||||
| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/agents/runtime.ts` (`convertFromMessages`) |
|
||||
| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
|
||||
|
||||
Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are
|
||||
reused untouched — calls set `voiceInput` per utterance and force
|
||||
`voiceOutput: 'full'`.
|
||||
|
||||
## Driving the app on a call
|
||||
|
||||
The assistant can drive the Rowboat UI itself via the extended
|
||||
`app-navigation` builtin ("app driver"): `open-view` (any main view),
|
||||
`read-view` (returns the emails / background agents / chat-history data the
|
||||
view renders — and the renderer simultaneously navigates there so the user
|
||||
watches it happen), and `open-item` (a specific email thread, note,
|
||||
background agent, or past chat, deep-linked on screen). Data comes from the
|
||||
same core functions the UI's IPC handlers use (`listImportantThreads` /
|
||||
`searchThreads`, background-task `listTasks`, the sessions container) — no
|
||||
OCR of screen frames. The renderer applies results via
|
||||
`applyAppNavigation` in App.tsx, fed from BOTH event paths: the legacy
|
||||
`runs:events` ref-poll AND a watcher over the session-chat conversation (the
|
||||
turn runtime does not emit legacy run events — miss this and navigation
|
||||
silently no-ops while the tool reports success). Session switches seed the
|
||||
watcher so replaying history never navigates. During a call, visible
|
||||
navigations also collapse the full-screen call to the pill and focus the app
|
||||
window (`app:focusMainWindow`) so the user actually sees the screen change.
|
||||
Card labels live in `lib/chat-conversation.ts`. The call prompt and the
|
||||
`app-navigation` skill teach the show-while-telling pattern: read-view →
|
||||
speak the highlights → open-item when the user picks one.
|
||||
|
||||
## Latency
|
||||
|
||||
Voice-to-voice latency (user stops talking → assistant audio) is engineered
|
||||
at four points; the `call_turn_latency` PostHog event measures the real
|
||||
distribution (utterance → submit → first speak → audio playing):
|
||||
|
||||
- **Smart endpointing** (`useVoiceMode.ts`): Deepgram endpoints at 600ms and
|
||||
the client decides — a transcript ending in terminal punctuation fires
|
||||
immediately (~600ms after last word); a mid-thought trail holds another
|
||||
1.2s (resumed speech cancels the hold). Complete sentences turn around
|
||||
~1.2s faster than the old fixed 1800ms endpoint.
|
||||
- **Streaming TTS** (`voice:synthesizeStreamStart` → `voice:tts-chunk` →
|
||||
MediaSource playback in `useVoiceTTS.ts`): the first segment of an idle
|
||||
queue plays from the first MP3 chunk instead of after the full body
|
||||
(ElevenLabs `/stream`, flash model). Follow-up segments keep the gapless
|
||||
full-body prefetch path. Falls back to non-streaming on any failure.
|
||||
- **Early clause speech** (`turn-view.ts` `applyOverlay`): a still-open
|
||||
`<voice>` block ≥60 chars emits its last complete clause immediately, so
|
||||
speech starts while the rest of the sentence generates.
|
||||
- **Acknowledgment cue** (`lib/call-sounds.ts`): a soft blip the instant an
|
||||
utterance is accepted — perceived latency matters as much as measured.
|
||||
|
||||
## Cost notes
|
||||
|
||||
Webcam frames ≈ 250–350 tokens each (≤12/message ≈ 3–4k); screen frames ≈
|
||||
1.5–2k tokens each (≤4/message ≈ 6–8k). History keeps frames inline, so long
|
||||
sessions grow but stay prefix-cached. First lever if cost bites: drop to one
|
||||
screen frame per message unless the screen changed.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Turn-taking is strict — no barge-in (would need echo cancellation against
|
||||
TTS output).
|
||||
- Frame sampling, not video: motion between frames is invisible (the prompt
|
||||
tells the model not to claim otherwise).
|
||||
- Vocal-delivery feedback is limited: Deepgram reduces speech to text, so
|
||||
"energy" coaching leans on visual cues.
|
||||
- Screen share always captures the primary display (no window/display
|
||||
picker yet).
|
||||
- The full-screen call covers the chat; there's no in-call transcript drawer.
|
||||
- The "attach camera frames to typed chat without a call" combination (the
|
||||
old video+chat mode) was cut in the call-model simplification; if analytics
|
||||
show demand, it should return as an attachment chip, not a mode.
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -66,6 +67,28 @@ for (const dir of fs.readdirSync(prebuildsDir)) {
|
|||
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
|
||||
// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any
|
||||
// host whose prebuild is absent (notably Linux) the staged package has no loadable
|
||||
// pty.node and the app crashes on launch. Compile the native module for the host
|
||||
// platform+arch if needed and stage it under prebuilds/<platform>-<arch>/, where
|
||||
// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp
|
||||
// step (the CI workflow's explicit build is the fast path; this is the safety net).
|
||||
const hostTriple = `${process.platform}-${process.arch}`;
|
||||
const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node');
|
||||
if (!fs.existsSync(stagedBinary)) {
|
||||
const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node');
|
||||
if (!fs.existsSync(builtBinary)) {
|
||||
console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`);
|
||||
execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' });
|
||||
}
|
||||
if (!fs.existsSync(builtBinary)) {
|
||||
throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(stagedBinary), { recursive: true });
|
||||
fs.copyFileSync(builtBinary, stagedBinary);
|
||||
console.log(`✅ node-pty: staged ${hostTriple}/pty.node`);
|
||||
}
|
||||
console.log('✅ node-pty staged in .package/node_modules');
|
||||
|
||||
// Bundle the vendored agent-slack CLI into a single self-contained script next
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@
|
|||
const path = require('path');
|
||||
const pkg = require('./package.json');
|
||||
|
||||
// The Arch Linux (pacman) package is meant only for local builds on an Arch host
|
||||
// with makepkg. It already self-skips elsewhere (maker-pacman checks for makepkg),
|
||||
// but CI sets ROWBOAT_SKIP_PACMAN=1 to disable it explicitly — GitHub runners are
|
||||
// Ubuntu and shouldn't attempt to ship an Arch package.
|
||||
const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1';
|
||||
|
||||
// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full
|
||||
// production dependency closure into the packaged app.
|
||||
//
|
||||
|
|
@ -14,53 +20,88 @@ const pkg = require('./package.json');
|
|||
// strips the workspace node_modules (see `ignore` below). Without this, packaged
|
||||
// builds throw `Cannot find module '@agentclientprotocol/...'`.
|
||||
//
|
||||
// Why we reconstruct a nested tree instead of copying node_modules: pnpm's store is a
|
||||
// Why we reconstruct the tree instead of copying node_modules: pnpm's store is a
|
||||
// symlink farm that legitimately holds multiple versions of the same package (e.g.
|
||||
// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style
|
||||
// nested node_modules — dereferencing symlinks and nesting on version conflict — which
|
||||
// resolves correctly regardless of pnpm layout.
|
||||
// node_modules — dereferencing symlinks — that resolves correctly regardless of pnpm
|
||||
// layout. We HOIST every package to the top-level node_modules and only nest a package
|
||||
// under its requirer on a genuine version conflict. Hoisting (vs. always nesting) keeps
|
||||
// the tree shallow: without it, transitive chains like codex-acp → open → wsl-utils →
|
||||
// is-wsl → is-inside-container → is-docker nest 5+ deep and produce ~260-char paths that
|
||||
// break the Windows Squirrel/nuget maker's MAX_PATH limit. Node resolution stays correct
|
||||
// because the top-level node_modules is an ancestor of every staged file, so a hoisted
|
||||
// package resolves for all requirers and a conflicting version shadows it via nesting.
|
||||
// verifyAcpStaging() below asserts this held for every dependency edge.
|
||||
//
|
||||
// What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped
|
||||
// as platform-specific packages). Those are PROVISIONED on demand into
|
||||
// ~/.rowboat/engines/<agent>/<version>/ and the adapters are pointed at them via
|
||||
// CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping
|
||||
// them keeps each OS installer ~400 MB smaller while code mode stays fully functional.
|
||||
// Shared by stageAcpAdapters and verifyAcpStaging so staging and verification use
|
||||
// identical resolution semantics.
|
||||
const ACP_ADAPTERS = [
|
||||
'@agentclientprotocol/claude-agent-acp',
|
||||
'@agentclientprotocol/codex-acp',
|
||||
];
|
||||
|
||||
// The native engines, shipped as platform packages. Provisioned on demand
|
||||
// (see header comment), so they're excluded from staging.
|
||||
const isAcpNativeEngine = (key) =>
|
||||
/^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude
|
||||
/^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex
|
||||
|
||||
// Resolve a dependency's real directory by walking node_modules the way Node does,
|
||||
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||
// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
|
||||
// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
|
||||
// Returns null for deps not installed for this OS (platform-optional binaries).
|
||||
const acpRealDirOf = (key, fromDir) => {
|
||||
const fs = require('fs');
|
||||
let dir = fromDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
function stageAcpAdapters(mainDir, destNodeModules) {
|
||||
const fs = require('fs');
|
||||
const ADAPTERS = [
|
||||
'@agentclientprotocol/claude-agent-acp',
|
||||
'@agentclientprotocol/codex-acp',
|
||||
];
|
||||
|
||||
// The native engines, shipped as platform packages. Provisioned on demand instead
|
||||
// (see comment above), so they're excluded from staging.
|
||||
const isNativeEngine = (key) =>
|
||||
/^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude
|
||||
/^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex
|
||||
|
||||
// Resolve a dependency's real directory by walking node_modules the way Node does,
|
||||
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||
// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
|
||||
// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
|
||||
// Returns null for deps not installed for this OS (platform-optional binaries).
|
||||
const realDirOf = (key, fromDir) => {
|
||||
let dir = fromDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
let copied = 0;
|
||||
const skippedEngines = new Set();
|
||||
const install = (srcDir, key, destNM, chain) => {
|
||||
const destDir = path.join(destNM, ...key.split('/'));
|
||||
if (fs.existsSync(destDir)) return; // already placed at this exact location
|
||||
// srcRealDir -> the staged directory whose content represents it. Lets
|
||||
// verifyAcpStaging map every source package to where it landed.
|
||||
const placements = new Map();
|
||||
// package key -> version placed at the TOP-LEVEL node_modules. We hoist every
|
||||
// package to the top level and only nest a package under its requirer when a
|
||||
// DIFFERENT version is already hoisted there. See the header comment for why
|
||||
// (a shallow tree stays under Windows' MAX_PATH).
|
||||
const rootHoisted = new Map();
|
||||
const install = (srcDir, key, parentNM, chain) => {
|
||||
if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const version = pj.version;
|
||||
const hoisted = rootHoisted.get(key);
|
||||
let destNM;
|
||||
if (hoisted === undefined) {
|
||||
destNM = destNodeModules; // first sighting → hoist to the top level
|
||||
rootHoisted.set(key, version);
|
||||
} else if (hoisted === version) {
|
||||
// identical version already hoisted at root → reuse it (its subtree is
|
||||
// already staged); just record where this srcDir resolves to.
|
||||
placements.set(srcDir, path.join(destNodeModules, ...key.split('/')));
|
||||
return;
|
||||
} else {
|
||||
destNM = parentNM; // genuine version conflict → nest under requirer
|
||||
}
|
||||
const destDir = path.join(destNM, ...key.split('/'));
|
||||
placements.set(srcDir, destDir);
|
||||
if (fs.existsSync(destDir)) return; // already placed at this exact location
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
fs.cpSync(srcDir, destDir, {
|
||||
recursive: true,
|
||||
|
|
@ -68,18 +109,17 @@ function stageAcpAdapters(mainDir, destNodeModules) {
|
|||
filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion
|
||||
});
|
||||
copied++;
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||
const nextChain = new Set(chain).add(srcDir);
|
||||
for (const depKey of Object.keys(deps)) {
|
||||
if (isNativeEngine(depKey)) { skippedEngines.add(depKey); continue; }
|
||||
const depDir = realDirOf(depKey, srcDir);
|
||||
if (isAcpNativeEngine(depKey)) { skippedEngines.add(depKey); continue; }
|
||||
const depDir = acpRealDirOf(depKey, srcDir);
|
||||
if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of ADAPTERS) {
|
||||
const srcDir = realDirOf(key, mainDir);
|
||||
for (const key of ACP_ADAPTERS) {
|
||||
const srcDir = acpRealDirOf(key, mainDir);
|
||||
if (!srcDir) {
|
||||
throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`);
|
||||
}
|
||||
|
|
@ -88,7 +128,64 @@ function stageAcpAdapters(mainDir, destNodeModules) {
|
|||
if (skippedEngines.size) {
|
||||
console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`);
|
||||
}
|
||||
return copied;
|
||||
return { copied, placements };
|
||||
}
|
||||
|
||||
// Fail the build LOUDLY if hoisting misplaced anything. Re-walk the source dependency
|
||||
// closure and assert that every (package → dependency) edge resolves, in the STAGED
|
||||
// tree, to the SAME version it resolves to in the SOURCE pnpm tree. This converts a
|
||||
// silent runtime "Cannot find module" (or a wrong-version resolution from a botched
|
||||
// hoist) into an immediate build failure. Expectations are derived from the source
|
||||
// tree — nothing is hardcoded — so it keeps working as the dependency set changes.
|
||||
function verifyAcpStaging(mainDir, placements) {
|
||||
const fs = require('fs');
|
||||
const versionAt = (dir) =>
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
||||
// Resolve `key`'s staged version as seen from `fromStagedDir`, via Node's own
|
||||
// upward node_modules walk. Reads package.json directly (not require.resolve, whose
|
||||
// `${key}/package.json` subpath some exports maps block).
|
||||
const stagedVersionOf = (key, fromStagedDir) => {
|
||||
let dir = fromStagedDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return versionAt(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
const errors = [];
|
||||
const visited = new Set();
|
||||
const walk = (srcDir) => {
|
||||
if (visited.has(srcDir)) return;
|
||||
visited.add(srcDir);
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
const stagedDir = placements.get(srcDir);
|
||||
if (!stagedDir) { errors.push(`not staged: ${pj.name}@${pj.version}`); return; }
|
||||
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||
for (const depKey of Object.keys(deps)) {
|
||||
if (isAcpNativeEngine(depKey)) continue;
|
||||
const depSrc = acpRealDirOf(depKey, srcDir);
|
||||
if (!depSrc) continue; // platform-optional / not installed for this OS
|
||||
const want = versionAt(depSrc);
|
||||
const got = stagedVersionOf(depKey, stagedDir);
|
||||
if (got === null) {
|
||||
errors.push(`${pj.name} → ${depKey}: unresolved in staged tree (expected ${want})`);
|
||||
} else if (got !== want) {
|
||||
errors.push(`${pj.name} → ${depKey}: staged resolves ${got}, source resolves ${want}`);
|
||||
}
|
||||
walk(depSrc);
|
||||
}
|
||||
};
|
||||
for (const key of ACP_ADAPTERS) {
|
||||
const srcDir = acpRealDirOf(key, mainDir);
|
||||
if (srcDir) walk(srcDir);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error(
|
||||
`ACP staging verification failed — the staged tree resolves differently than source:\n - ${errors.join('\n - ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
@ -102,6 +199,7 @@ module.exports = {
|
|||
],
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
NSCameraUsageDescription: 'Rowboat uses your camera in video chat mode so the assistant can see you and give feedback (e.g. pitch practice).',
|
||||
},
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
|
|
@ -178,7 +276,8 @@ module.exports = {
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Arch Linux package — local-only; disabled in CI via ROWBOAT_SKIP_PACMAN.
|
||||
...(SKIP_PACMAN ? [] : [{
|
||||
name: require.resolve('./makers/maker-pacman.cjs'),
|
||||
platforms: ['linux'],
|
||||
config: {
|
||||
|
|
@ -192,7 +291,7 @@ module.exports = {
|
|||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
},
|
||||
}]),
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platform: ["darwin", "win32", "linux"],
|
||||
|
|
@ -291,10 +390,12 @@ module.exports = {
|
|||
// must be copied in explicitly. See stageAcpAdapters() above for the why.
|
||||
console.log('Staging ACP adapters...');
|
||||
const acpDest = path.join(packageDir, 'acp', 'node_modules');
|
||||
const staged = stageAcpAdapters(__dirname, acpDest);
|
||||
console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules`);
|
||||
const { copied: staged, placements } = stageAcpAdapters(__dirname, acpDest);
|
||||
// Assert the hoisted tree resolves identically to source before shipping it.
|
||||
verifyAcpStaging(__dirname, placements);
|
||||
console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules (resolution verified)`);
|
||||
|
||||
console.log('✅ All assets staged in .package/');
|
||||
},
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import { browserViewManager, type BrowserState } from './view.js';
|
||||
import { browserViewManager, type BrowserState, type HttpAuthRequest } from './view.js';
|
||||
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ type BrowserHandlers = {
|
|||
'browser:forward': InvokeHandler<'browser:forward'>;
|
||||
'browser:reload': InvokeHandler<'browser:reload'>;
|
||||
'browser:getState': InvokeHandler<'browser:getState'>;
|
||||
'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +63,9 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
'browser:getState': async () => {
|
||||
return browserViewManager.getState();
|
||||
},
|
||||
'browser:httpAuthResponse': async (_event, args) => {
|
||||
return browserViewManager.respondToHttpAuth(args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -70,12 +74,26 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
* window is created so the manager has a window to attach to.
|
||||
*/
|
||||
export function setupBrowserEventForwarding(): void {
|
||||
browserViewManager.on('state-updated', (state: BrowserState) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('browser:didUpdateState', state);
|
||||
}
|
||||
// Only send to app windows, never to OAuth/SSO popup windows created by
|
||||
// page window.open() — those render untrusted web content, and browsing
|
||||
// state / auth-challenge metadata must not cross into them.
|
||||
const broadcast = (channel: string, payload: unknown) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed() || !win.webContents) continue;
|
||||
if (browserViewManager.isPopupWindow(win)) continue;
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
};
|
||||
|
||||
browserViewManager.on('state-updated', (state: BrowserState) => {
|
||||
broadcast('browser:didUpdateState', state);
|
||||
});
|
||||
|
||||
browserViewManager.on('http-auth-request', (request: HttpAuthRequest) => {
|
||||
broadcast('browser:httpAuthRequest', request);
|
||||
});
|
||||
|
||||
browserViewManager.on('http-auth-resolved', (requestId: string) => {
|
||||
broadcast('browser:httpAuthResolved', { requestId });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session, type WebContents } from 'electron';
|
||||
import type {
|
||||
BrowserPageElement,
|
||||
BrowserPageSnapshot,
|
||||
BrowserState,
|
||||
BrowserTabState,
|
||||
HttpAuthRequest,
|
||||
} from '@x/shared/dist/browser-control.js';
|
||||
import { normalizeNavigationTarget } from './navigation.js';
|
||||
import {
|
||||
|
|
@ -20,7 +21,7 @@ import {
|
|||
type RawBrowserPageSnapshot,
|
||||
} from './page-scripts.js';
|
||||
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState, HttpAuthRequest };
|
||||
|
||||
/**
|
||||
* Embedded browser pane implementation.
|
||||
|
|
@ -36,13 +37,33 @@ export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
|
|||
|
||||
export const BROWSER_PARTITION = 'persist:rowboat-browser';
|
||||
|
||||
// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
|
||||
// that sniff the UA looking for "real browser" shapes.
|
||||
const SPOOF_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
|
||||
// Spoof a real Chrome UA so OAuth servers don't reject the embedded browser.
|
||||
// The Chrome major version is derived from the running Chromium at startup:
|
||||
// pinning a fixed version goes stale as Electron upgrades, and Chromium keeps
|
||||
// emitting Sec-CH-UA client hints with the *real* version — a UA/client-hint
|
||||
// version mismatch is a classic bot-detection signal (Google sign-in,
|
||||
// Cloudflare). Minor version is frozen at 0.0.0, exactly like real Chrome's
|
||||
// reduced UA. The platform token matches the actual OS for the same reason.
|
||||
function getChromeMajorVersion(): number {
|
||||
const major = Number.parseInt(process.versions.chrome ?? '', 10);
|
||||
return Number.isFinite(major) && major > 0 ? major : 130;
|
||||
}
|
||||
|
||||
function buildChromeUserAgent(): string {
|
||||
const platformToken =
|
||||
process.platform === 'darwin'
|
||||
? 'Macintosh; Intel Mac OS X 10_15_7'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows NT 10.0; Win64; x64'
|
||||
: 'X11; Linux x86_64';
|
||||
return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${getChromeMajorVersion()}.0.0.0 Safari/537.36`;
|
||||
}
|
||||
|
||||
const SPOOF_UA = buildChromeUserAgent();
|
||||
|
||||
const HOME_URL = 'https://www.google.com';
|
||||
const NAVIGATION_TIMEOUT_MS = 10000;
|
||||
const HTTP_AUTH_TIMEOUT_MS = 120000;
|
||||
const POST_ACTION_IDLE_MS = 400;
|
||||
const POST_ACTION_MAX_ELEMENTS = 25;
|
||||
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
|
||||
|
|
@ -68,6 +89,13 @@ type CachedSnapshot = {
|
|||
elements: Array<{ index: number; selector: string }>;
|
||||
};
|
||||
|
||||
type PendingHttpAuth = {
|
||||
callback: (username?: string, password?: string) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
// The webContents that raised the challenge, so its teardown can cancel it.
|
||||
webContents: WebContents;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
|
|
@ -109,6 +137,12 @@ export class BrowserViewManager extends EventEmitter {
|
|||
private visible = false;
|
||||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
private snapshotCache = new Map<string, CachedSnapshot>();
|
||||
private pendingHttpAuth = new Map<string, PendingHttpAuth>();
|
||||
// Child windows created by page window.open() (OAuth/SSO popups). Tracked so
|
||||
// they can be closed when the host window goes away — otherwise an orphaned
|
||||
// popup keeps BrowserWindow.getAllWindows() non-empty and, on macOS, blocks
|
||||
// the app from reopening via the Dock (see main.ts 'activate' handler).
|
||||
private popupWindows = new Set<BrowserWindow>();
|
||||
private cleanupWindowListeners: (() => void) | null = null;
|
||||
|
||||
attach(window: BrowserWindow): void {
|
||||
|
|
@ -137,6 +171,7 @@ export class BrowserViewManager extends EventEmitter {
|
|||
if (this.window !== window) return;
|
||||
|
||||
const tabs = [...this.tabs.values()];
|
||||
const popups = [...this.popupWindows];
|
||||
this.cleanupWindowListeners = null;
|
||||
this.window = null;
|
||||
this.browserSession = null;
|
||||
|
|
@ -150,6 +185,14 @@ export class BrowserViewManager extends EventEmitter {
|
|||
this.attachedTabId = null;
|
||||
this.visible = false;
|
||||
this.snapshotCache.clear();
|
||||
for (const requestId of [...this.pendingHttpAuth.keys()]) {
|
||||
this.finishHttpAuth(requestId);
|
||||
}
|
||||
// Close any OAuth/SSO popups so they don't outlive the app window.
|
||||
for (const popup of popups) {
|
||||
if (!popup.isDestroyed()) popup.close();
|
||||
}
|
||||
this.popupWindows.clear();
|
||||
};
|
||||
|
||||
hostWebContents.on('did-start-loading', handleDidStartLoading);
|
||||
|
|
@ -171,6 +214,36 @@ export class BrowserViewManager extends EventEmitter {
|
|||
if (this.browserSession) return this.browserSession;
|
||||
const browserSession = session.fromPartition(BROWSER_PARTITION);
|
||||
browserSession.setUserAgent(SPOOF_UA);
|
||||
|
||||
// Electron's Sec-CH-UA client hints only carry the "Chromium" brand;
|
||||
// real Chrome also sends "Google Chrome". Some sign-in flows (notably
|
||||
// Google's) distinguish the two, so rewrite the brand list to match what
|
||||
// Chrome sends. Both the low-entropy header (`sec-ch-ua`, major versions)
|
||||
// and the high-entropy one (`sec-ch-ua-full-version-list`, requested via
|
||||
// Accept-CH and carrying full versions) must be rewritten together — a
|
||||
// header that claims "Google Chrome" alongside one that doesn't is a
|
||||
// stronger bot signal than the original. Only headers Chromium already
|
||||
// attached are rewritten — none are added. (navigator.userAgentData JS
|
||||
// brands still report only Chromium; there is no reliable hook to spoof
|
||||
// that under sandbox+contextIsolation, and header-based detection is the
|
||||
// common case.)
|
||||
const chromeMajor = getChromeMajorVersion();
|
||||
const chromeFull = process.versions.chrome ?? `${chromeMajor}.0.0.0`;
|
||||
const brandLists: Record<string, string> = {
|
||||
'sec-ch-ua': `"Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}", "Not-A.Brand";v="99"`,
|
||||
'sec-ch-ua-full-version-list': `"Chromium";v="${chromeFull}", "Google Chrome";v="${chromeFull}", "Not-A.Brand";v="99.0.0.0"`,
|
||||
};
|
||||
browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const requestHeaders = details.requestHeaders;
|
||||
for (const name of Object.keys(requestHeaders)) {
|
||||
const replacement = brandLists[name.toLowerCase()];
|
||||
if (replacement !== undefined) {
|
||||
requestHeaders[name] = replacement;
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders });
|
||||
});
|
||||
|
||||
this.browserSession = browserSession;
|
||||
return browserSession;
|
||||
}
|
||||
|
|
@ -196,17 +269,34 @@ export class BrowserViewManager extends EventEmitter {
|
|||
return /^https?:\/\//i.test(url) || url === 'about:blank';
|
||||
}
|
||||
|
||||
/**
|
||||
* webPreferences shared by browser tabs and OAuth popups. Kept in one place
|
||||
* so the security-sensitive popup surface can never drift from tabs.
|
||||
*/
|
||||
private browserWebPreferences(): Electron.WebPreferences {
|
||||
return {
|
||||
session: this.getSession(),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
// Chromium's built-in PDFium viewer, so PDFs render inline instead
|
||||
// of showing a blank page.
|
||||
plugins: true,
|
||||
// Remove the WebAuthn API from the embedded browser only. Electron ships
|
||||
// the API but not Chrome's authenticator UI (Touch ID sheet, QR/phone
|
||||
// hybrid), so passkey challenges hang forever on "Verifying it's you...".
|
||||
// With the API absent, sites feature-detect it and fall back to
|
||||
// password/other verification. Scoped here (not app-wide) so the app's
|
||||
// own renderer keeps WebAuthn.
|
||||
disableBlinkFeatures: 'WebAuth',
|
||||
};
|
||||
}
|
||||
|
||||
private createView(): WebContentsView {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: this.getSession(),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
webPreferences: this.browserWebPreferences(),
|
||||
});
|
||||
|
||||
view.webContents.setUserAgent(SPOOF_UA);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
|
@ -266,16 +356,145 @@ export class BrowserViewManager extends EventEmitter {
|
|||
});
|
||||
wc.on('page-title-updated', this.emitState.bind(this));
|
||||
|
||||
wc.setWindowOpenHandler(({ url }) => {
|
||||
if (this.isEmbeddedTabUrl(url)) {
|
||||
void this.newTab(url);
|
||||
} else {
|
||||
void shell.openExternal(url);
|
||||
this.wireWindowPolicy(wc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-open, popup, and HTTP-auth wiring shared by tabs and popups.
|
||||
*/
|
||||
private wireWindowPolicy(wc: WebContents): void {
|
||||
wc.setWindowOpenHandler((details) => this.handleWindowOpen(details));
|
||||
wc.on('did-create-window', (child) => this.wirePopupWindow(child));
|
||||
this.wireHttpAuth(wc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared window.open / target=_blank policy for tabs and popups.
|
||||
*
|
||||
* An open that hands a handle back to the opener must become a real child
|
||||
* window so window.opener / postMessage survive — this is how OAuth/SSO
|
||||
* popups (Google, Microsoft, Plaid, ...) return their result; denying them
|
||||
* also makes sites report "popup blocked". Those are: a sized popup
|
||||
* (disposition 'new-window'), a *named* window.open(url, 'name') (non-empty
|
||||
* frameName), or a scripted blank window the opener will populate
|
||||
* (about:blank). A nameless target=_blank link (foreground-tab, empty
|
||||
* frameName) has no opener contract and opens as a tab, matching browser
|
||||
* behavior. Non-web schemes go to the system handler.
|
||||
*
|
||||
* Residual gap: a nameless, featureless window.open(url) is indistinguishable
|
||||
* from a _blank link (both foreground-tab + empty frameName) and opens as a
|
||||
* tab, losing its opener — rare for OAuth, which virtually always names or
|
||||
* sizes its popup.
|
||||
*/
|
||||
private handleWindowOpen(details: Electron.HandlerDetails): Electron.WindowOpenHandlerResponse {
|
||||
const { url, disposition, frameName } = details;
|
||||
|
||||
if (this.isEmbeddedTabUrl(url)) {
|
||||
const needsOpener =
|
||||
disposition === 'new-window' || frameName !== '' || url === 'about:blank';
|
||||
if (needsOpener) {
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: this.browserWebPreferences(),
|
||||
},
|
||||
};
|
||||
}
|
||||
void this.newTab(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
|
||||
void shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
|
||||
private wirePopupWindow(child: BrowserWindow): void {
|
||||
this.popupWindows.add(child);
|
||||
child.once('closed', () => this.popupWindows.delete(child));
|
||||
this.wireWindowPolicy(child.webContents);
|
||||
}
|
||||
|
||||
/** True if `win` is an OAuth/SSO popup created by page window.open(). */
|
||||
isPopupWindow(win: BrowserWindow): boolean {
|
||||
return this.popupWindows.has(win);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP basic/proxy auth. Chromium's default is to cancel the challenge, so
|
||||
* 401-protected sites and authenticating proxies dead-end. When the browser
|
||||
* pane is on screen to answer, forward the challenge to it as a credential
|
||||
* prompt (cancelled after a timeout if unanswered). When the pane is closed
|
||||
* — e.g. agent-driven navigation — don't preventDefault, so Chromium cancels
|
||||
* immediately and the 401 page is readable rather than hanging.
|
||||
*/
|
||||
private wireHttpAuth(wc: WebContents): void {
|
||||
wc.on('login', (event, _details, authInfo, callback) => {
|
||||
if (!this.visible || !this.window) return;
|
||||
event.preventDefault();
|
||||
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
this.finishHttpAuth(requestId);
|
||||
}, HTTP_AUTH_TIMEOUT_MS);
|
||||
this.pendingHttpAuth.set(requestId, { callback, timer, webContents: wc });
|
||||
// If the challenging contents dies before an answer, resolve now so the
|
||||
// native callback and timer don't leak (backstop for paths other than
|
||||
// destroyTab, which cancels explicitly before removeAllListeners()).
|
||||
wc.once('destroyed', () => this.finishHttpAuth(requestId));
|
||||
|
||||
const request: HttpAuthRequest = {
|
||||
requestId,
|
||||
host: authInfo.host,
|
||||
isProxy: authInfo.isProxy,
|
||||
...(authInfo.realm ? { realm: authInfo.realm } : {}),
|
||||
};
|
||||
this.emit('http-auth-request', request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending auth challenge. `username === undefined` cancels it; an
|
||||
* empty-string username is a valid submission (token-style Basic auth).
|
||||
* Always notifies the renderer so a dialog it may still be showing (e.g.
|
||||
* after a timeout or tab close) is pruned.
|
||||
*/
|
||||
private finishHttpAuth(requestId: string, username?: string, password?: string): boolean {
|
||||
const pending = this.pendingHttpAuth.get(requestId);
|
||||
if (!pending) return false;
|
||||
this.pendingHttpAuth.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
try {
|
||||
if (username == null) {
|
||||
pending.callback();
|
||||
} else {
|
||||
pending.callback(username, password ?? '');
|
||||
}
|
||||
} catch {
|
||||
// The challenged webContents may already be destroyed.
|
||||
}
|
||||
this.emit('http-auth-resolved', requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelHttpAuthForWebContents(wc: WebContents): void {
|
||||
const ids: string[] = [];
|
||||
for (const [requestId, pending] of this.pendingHttpAuth) {
|
||||
if (pending.webContents === wc) ids.push(requestId);
|
||||
}
|
||||
for (const requestId of ids) {
|
||||
this.finishHttpAuth(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
respondToHttpAuth(input: {
|
||||
requestId: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}): { ok: boolean } {
|
||||
return { ok: this.finishHttpAuth(input.requestId, input.username, input.password) };
|
||||
}
|
||||
|
||||
private snapshotTabState(tab: BrowserTab): BrowserTabState {
|
||||
const wc = tab.view.webContents;
|
||||
return {
|
||||
|
|
@ -364,6 +583,10 @@ export class BrowserViewManager extends EventEmitter {
|
|||
|
||||
private destroyTab(tab: BrowserTab): void {
|
||||
this.invalidateSnapshot(tab.id);
|
||||
// Cancel any auth challenge this tab raised before we drop its listeners,
|
||||
// so the native callback + timer don't leak and the renderer prunes its
|
||||
// dialog (removeAllListeners() below would kill the 'destroyed' backstop).
|
||||
this.cancelHttpAuthForWebContents(tab.view.webContents);
|
||||
tab.view.webContents.removeAllListeners();
|
||||
if (!tab.view.webContents.isDestroyed()) {
|
||||
tab.view.webContents.close();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron';
|
||||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app, screen } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
connectProvider,
|
||||
disconnectProvider,
|
||||
|
|
@ -24,6 +25,8 @@ const execFileAsync = promisify(execFile);
|
|||
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
|
||||
import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
|
|
@ -49,6 +52,8 @@ import type { CodeSession } from '@x/shared/dist/code-sessions.js';
|
|||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { IChannelsConfigRepo } from '@x/core/dist/channels/repo.js';
|
||||
import { applyChannelsConfig, getChannelsStatus, logoutWhatsApp, subscribeChannelsStatus } from '@x/core/dist/channels/service.js';
|
||||
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
|
||||
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
|
||||
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
||||
|
|
@ -62,6 +67,9 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
|||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { resolveMeetingPrep } from '@x/core/dist/knowledge/meeting_prep.js';
|
||||
import { readPrepNoteForEvent } from '@x/core/dist/knowledge/meeting_prep_brief.js';
|
||||
import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index.js';
|
||||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
|
|
@ -69,7 +77,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
||||
|
|
@ -376,9 +384,37 @@ type InvokeHandlers = {
|
|||
[K in InvokeChannels]: InvokeHandler<K>;
|
||||
};
|
||||
|
||||
// In-flight streaming TTS requests, keyed by renderer-chosen requestId.
|
||||
const activeTtsStreams = new Map<string, AbortController>();
|
||||
|
||||
// Video-mode popout window (shown for the whole duration of a screen share,
|
||||
// floating over every app including Rowboat itself) and the last call state
|
||||
// pushed by the main window — replayed to the popout when it finishes loading.
|
||||
let videoPopoutWin: BrowserWindow | null = null;
|
||||
let lastVideoPopoutState: {
|
||||
ttsState: 'idle' | 'synthesizing' | 'speaking';
|
||||
status: 'listening' | 'thinking' | 'speaking' | null;
|
||||
cameraOn: boolean;
|
||||
micMuted: boolean;
|
||||
screenSharing: boolean;
|
||||
interimText: string | null;
|
||||
} | null = null;
|
||||
|
||||
// Match only real app windows — getAllWindows() can also contain the popout
|
||||
// itself and hidden utility windows (e.g. PDF-export renderers), which must
|
||||
// not be shown, focused, or sent app events.
|
||||
function findMainAppWindow(): BrowserWindow | undefined {
|
||||
return BrowserWindow.getAllWindows().find((w) => {
|
||||
if (w === videoPopoutWin || w.isDestroyed()) return false;
|
||||
const url = w.webContents.getURL();
|
||||
const isAppWindow = url.startsWith('app://') || url.startsWith('http://localhost');
|
||||
return isAppWindow && !url.includes('#video-popout');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC handlers with type safety and runtime validation
|
||||
*
|
||||
*
|
||||
* This function ensures:
|
||||
* 1. All invoke channels have handlers (exhaustiveness checking)
|
||||
* 2. Handler signatures match channel definitions
|
||||
|
|
@ -507,7 +543,25 @@ function queueChange(relPath: string): void {
|
|||
/**
|
||||
* Handle workspace change event from core watcher
|
||||
*/
|
||||
function touchesKnowledge(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): boolean {
|
||||
const hit = (p: string | undefined) => typeof p === 'string' && p.startsWith('knowledge/');
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
return hit(event.path);
|
||||
case 'moved':
|
||||
return hit(event.from) || hit(event.to);
|
||||
case 'bulkChanged':
|
||||
return !event.paths || event.paths.some(hit);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
||||
// Any knowledge-base change drops the cached index so the next read rebuilds.
|
||||
if (touchesKnowledge(event)) invalidateKnowledgeIndex();
|
||||
// Debounce 'changed' events, emit others immediately
|
||||
if (event.type === 'changed' && event.path) {
|
||||
queueChange(event.path);
|
||||
|
|
@ -569,6 +623,9 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
|
|||
}
|
||||
|
||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
|
||||
// Native connection status (e.g. Google) is baked into the Copilot system
|
||||
// prompt, so any OAuth state change must rebuild it.
|
||||
invalidateCopilotInstructionsCache();
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
|
|
@ -613,6 +670,52 @@ export async function startRunsWatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
// New runtime: session bus → renderer windows (session-design.md §10).
|
||||
function emitSessionEvent(event: SessionBusEvent): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('sessions:events', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile channels: status changes (QR pairing, connect/disconnect) → renderer.
|
||||
let channelsWatcher: (() => void) | null = null;
|
||||
export function startChannelsWatcher(): void {
|
||||
if (channelsWatcher) return;
|
||||
channelsWatcher = subscribeChannelsStatus((status) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('channels:status', status);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let sessionsWatcher: (() => void) | null = null;
|
||||
export function startSessionsWatcher(): void {
|
||||
if (sessionsWatcher) {
|
||||
return;
|
||||
}
|
||||
const sessionBus = container.resolve<EmitterSessionBus>('sessionBus');
|
||||
sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event));
|
||||
}
|
||||
|
||||
// The renderer window is created before the session-index startup scan
|
||||
// finishes, so an early sessions:list could observe a partially built index
|
||||
// (the scan runs oldest-first — exactly the newest chats would be missing).
|
||||
// sessions:list awaits this deferred; main.ts resolves it when the scan
|
||||
// settles (success or failure, so the list never hangs).
|
||||
let resolveSessionsIndexReady: () => void;
|
||||
const sessionsIndexReady = new Promise<void>((resolve) => {
|
||||
resolveSessionsIndexReady = resolve;
|
||||
});
|
||||
export function markSessionsIndexReady(): void {
|
||||
resolveSessionsIndexReady();
|
||||
}
|
||||
|
||||
let servicesWatcher: (() => void) | null = null;
|
||||
export async function startServicesWatcher(): Promise<void> {
|
||||
if (servicesWatcher) {
|
||||
|
|
@ -740,6 +843,18 @@ export function setupIpcHandlers() {
|
|||
'gmail:sendReply': async (_event, args) => {
|
||||
return sendThreadReply(args);
|
||||
},
|
||||
'gmail:saveDraft': async (_event, args) => {
|
||||
return saveThreadDraft(args);
|
||||
},
|
||||
'gmail:deleteDraft': async (_event, args) => {
|
||||
return deleteThreadDraft(args.draftId);
|
||||
},
|
||||
'gmail:getDrafts': async () => {
|
||||
return listDraftThreads();
|
||||
},
|
||||
'gmail:search': async (_event, args) => {
|
||||
return searchThreads(args.query, { limit: args.limit });
|
||||
},
|
||||
'gmail:getConnectionStatus': async () => {
|
||||
return getGmailConnectionStatus();
|
||||
},
|
||||
|
|
@ -749,6 +864,10 @@ export function setupIpcHandlers() {
|
|||
'gmail:getAccountName': async () => {
|
||||
return { name: await getAccountName() };
|
||||
},
|
||||
'gmail:setImportance': async (_event, args) => {
|
||||
const result = setThreadImportance(args.threadId, args.importance);
|
||||
return { ok: result.success, previous: result.previous, error: result.error };
|
||||
},
|
||||
'gmail:archiveThread': async (_event, args) => {
|
||||
return archiveThread(args.threadId);
|
||||
},
|
||||
|
|
@ -756,7 +875,10 @@ export function setupIpcHandlers() {
|
|||
return trashThread(args.threadId);
|
||||
},
|
||||
'gmail:markThreadRead': async (_event, args) => {
|
||||
return markThreadRead(args.threadId);
|
||||
return markThreadRead(args.threadId, args.read);
|
||||
},
|
||||
'gmail:downloadAttachment': async (_event, args) => {
|
||||
return downloadAttachment(args);
|
||||
},
|
||||
'gmail:saveMessageHeight': async (_event, args) => {
|
||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
|
|
@ -813,10 +935,90 @@ export function setupIpcHandlers() {
|
|||
'runs:list': async (_event, args) => {
|
||||
return runsCore.listRuns(args.cursor);
|
||||
},
|
||||
'runs:listByWorkDir': async (_event, args) => {
|
||||
return runsCore.listRunsByWorkDir(args.dir);
|
||||
},
|
||||
'runs:delete': async (_event, args) => {
|
||||
await runsCore.deleteRun(args.runId);
|
||||
return { success: true };
|
||||
},
|
||||
// ── New runtime: sessions + turns ─────────────────────────
|
||||
// Thin pass-throughs to the sessions service. sendMessage returns the
|
||||
// turnId immediately; the turn advances in the background and the
|
||||
// renderer reconciles via the sessions:events feed. Input-routing calls
|
||||
// settle with that advance's outcome (the renderer fire-and-forgets).
|
||||
'sessions:create': async (_event, args) => {
|
||||
const sessionId = await container.resolve<ISessions>('sessions').createSession(args);
|
||||
return { sessionId };
|
||||
},
|
||||
'sessions:list': async () => {
|
||||
await sessionsIndexReady;
|
||||
return { sessions: container.resolve<ISessions>('sessions').listSessions() };
|
||||
},
|
||||
'sessions:get': async (_event, args) => {
|
||||
return container.resolve<ISessions>('sessions').getSession(args.sessionId);
|
||||
},
|
||||
'sessions:getTurn': async (_event, args) => {
|
||||
return container.resolve<ISessions>('sessions').getTurn(args.turnId);
|
||||
},
|
||||
'sessions:sendMessage': async (_event, args) => {
|
||||
return container.resolve<ISessions>('sessions').sendMessage(args.sessionId, args.input, args.config);
|
||||
},
|
||||
'sessions:respondToPermission': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').respondToPermission(args.turnId, args.toolCallId, args.decision, args.metadata);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:respondToAskHuman': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').respondToAskHuman(args.turnId, args.toolCallId, args.answer);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:stopTurn': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').stopTurn(args.turnId, args.reason);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:resumeTurn': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').resumeTurn(args.sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:setTitle': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').setTitle(args.sessionId, args.title);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:delete': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').deleteSession(args.sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:downloadLog': async (event, args) => {
|
||||
// Concatenate the session's turn logs into one JSONL for debugging.
|
||||
const sessions = container.resolve<ISessions>('sessions');
|
||||
const state = await sessions.getSession(args.sessionId);
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${args.sessionId}.jsonl.log`,
|
||||
filters: [
|
||||
{ name: 'Chat Log', extensions: ['log'] },
|
||||
{ name: 'JSONL', extensions: ['jsonl'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
for (const ref of state.turns) {
|
||||
const turn = await sessions.getTurn(ref.turnId);
|
||||
for (const turnEvent of turn.events) {
|
||||
lines.push(JSON.stringify(turnEvent));
|
||||
}
|
||||
}
|
||||
await fs.writeFile(result.filePath, lines.join('\n') + '\n');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'runs:downloadLog': async (event, args) => {
|
||||
const runFileName = `${args.runId}.jsonl`;
|
||||
if (path.basename(runFileName) !== runFileName) {
|
||||
|
|
@ -1072,6 +1274,22 @@ export function setupIpcHandlers() {
|
|||
|
||||
return { success: true };
|
||||
},
|
||||
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
|
||||
'channels:getConfig': async () => {
|
||||
return container.resolve<IChannelsConfigRepo>('channelsConfigRepo').getConfig();
|
||||
},
|
||||
'channels:setConfig': async (_event, args) => {
|
||||
await container.resolve<IChannelsConfigRepo>('channelsConfigRepo').setConfig(args);
|
||||
await applyChannelsConfig(args);
|
||||
return { success: true };
|
||||
},
|
||||
'channels:getStatus': async () => {
|
||||
return getChannelsStatus();
|
||||
},
|
||||
'channels:whatsappLogout': async () => {
|
||||
await logoutWhatsApp();
|
||||
return { success: true };
|
||||
},
|
||||
'slack:getConfig': async () => {
|
||||
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
|
|
@ -1541,6 +1759,11 @@ export function setupIpcHandlers() {
|
|||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
return { notes };
|
||||
},
|
||||
'meeting-prep:resolve': async (_event, args) => {
|
||||
const result = await resolveMeetingPrep(args.attendees);
|
||||
const prepNote = args.eventId ? await readPrepNoteForEvent(args.eventId) : null;
|
||||
return { ...result, prepNote };
|
||||
},
|
||||
'inline-task:classifySchedule': async (_event, args) => {
|
||||
const schedule = await classifySchedule(args.instruction);
|
||||
return { schedule };
|
||||
|
|
@ -1554,6 +1777,51 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
'voice:synthesizeStreamStart': async (event, args) => {
|
||||
const { requestId, text } = args;
|
||||
const sender = event.sender;
|
||||
const controller = new AbortController();
|
||||
activeTtsStreams.set(requestId, controller);
|
||||
// Fire-and-forget: chunks are pushed to the renderer as they arrive so
|
||||
// playback can begin immediately; the invoke returns once started.
|
||||
void voice
|
||||
.synthesizeSpeechStream(
|
||||
text,
|
||||
(chunk) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('voice:tts-chunk', {
|
||||
requestId,
|
||||
chunkBase64: chunk.toString('base64'),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then(() => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('voice:tts-chunk', { requestId, done: true });
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!sender.isDestroyed() && !controller.signal.aborted) {
|
||||
sender.send('voice:tts-chunk', {
|
||||
requestId,
|
||||
done: true,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
activeTtsStreams.delete(requestId);
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
'voice:synthesizeStreamCancel': async (_event, args) => {
|
||||
activeTtsStreams.get(args.requestId)?.abort();
|
||||
activeTtsStreams.delete(args.requestId);
|
||||
return {};
|
||||
},
|
||||
'voice:ensureMicAccess': async () => {
|
||||
if (process.platform !== 'darwin') return { granted: true };
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
|
|
@ -1572,6 +1840,113 @@ export function setupIpcHandlers() {
|
|||
return { granted: false };
|
||||
}
|
||||
},
|
||||
'voice:ensureCameraAccess': async () => {
|
||||
if (process.platform !== 'darwin') return { granted: true };
|
||||
const status = systemPreferences.getMediaAccessStatus('camera');
|
||||
console.log('[video] Camera permission status:', status);
|
||||
if (status === 'granted') return { granted: true };
|
||||
// Same flow as the microphone: settle the native TCC prompt before the
|
||||
// renderer's getUserMedia so the first video click doesn't silently fail.
|
||||
try {
|
||||
const granted = await systemPreferences.askForMediaAccess('camera');
|
||||
console.log('[video] Camera permission after prompt:', granted);
|
||||
return { granted };
|
||||
} catch {
|
||||
return { granted: false };
|
||||
}
|
||||
},
|
||||
'video:setPopout': async (_event, args) => {
|
||||
if (!args.show) {
|
||||
if (videoPopoutWin && !videoPopoutWin.isDestroyed()) videoPopoutWin.destroy();
|
||||
videoPopoutWin = null;
|
||||
return {};
|
||||
}
|
||||
if (videoPopoutWin && !videoPopoutWin.isDestroyed()) return {};
|
||||
|
||||
const workArea = screen.getPrimaryDisplay().workArea;
|
||||
const width = 340;
|
||||
const height = 184;
|
||||
const ipcDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const preloadPath = app.isPackaged
|
||||
? path.join(ipcDir, '../preload/dist/preload.js')
|
||||
: path.join(ipcDir, '../../../preload/dist/preload.js');
|
||||
const win = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
x: workArea.x + workArea.width - width - 24,
|
||||
y: workArea.y + 24,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
show: false,
|
||||
hasShadow: true,
|
||||
backgroundColor: '#171717',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: preloadPath,
|
||||
},
|
||||
});
|
||||
// Float above other apps on every workspace. Deliberately NOT
|
||||
// `visibleOnFullScreen: true`: on macOS that flag hides the app's Dock
|
||||
// icon for as long as such a window exists (the app becomes an
|
||||
// "agent" app), which reads as Rowboat having vanished. The trade-off
|
||||
// is the popout won't hover over other apps' fullscreen Spaces.
|
||||
win.setAlwaysOnTop(true, 'floating');
|
||||
win.setVisibleOnAllWorkspaces(true);
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (lastVideoPopoutState) {
|
||||
win.webContents.send('video:popout-state', lastVideoPopoutState);
|
||||
}
|
||||
// showInactive: appearing must not steal focus from the app the user
|
||||
// switched to — that would immediately re-hide the popout.
|
||||
if (!win.isDestroyed()) win.showInactive();
|
||||
});
|
||||
win.on('closed', () => {
|
||||
if (videoPopoutWin === win) videoPopoutWin = null;
|
||||
});
|
||||
videoPopoutWin = win;
|
||||
if (app.isPackaged) {
|
||||
win.loadURL('app://-/index.html#video-popout');
|
||||
} else {
|
||||
win.loadURL('http://localhost:5173/#video-popout');
|
||||
}
|
||||
return {};
|
||||
},
|
||||
'video:popoutState': async (_event, args) => {
|
||||
lastVideoPopoutState = args;
|
||||
if (videoPopoutWin && !videoPopoutWin.isDestroyed()) {
|
||||
videoPopoutWin.webContents.send('video:popout-state', args);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
'app:focusMainWindow': async () => {
|
||||
const main = findMainAppWindow();
|
||||
if (main) {
|
||||
if (main.isMinimized()) main.restore();
|
||||
main.show();
|
||||
main.focus();
|
||||
}
|
||||
return {};
|
||||
},
|
||||
'video:getPopoutState': async () => {
|
||||
return { state: lastVideoPopoutState };
|
||||
},
|
||||
'video:popoutAction': async (_event, args) => {
|
||||
// Relay a popout control-bar action to the app window, which owns the
|
||||
// call (mic, camera, screen capture) and executes it there. 'expand'
|
||||
// additionally brings the app window back to the foreground.
|
||||
const main = findMainAppWindow();
|
||||
if (args.action === 'expand' && main) {
|
||||
if (main.isMinimized()) main.restore();
|
||||
main.show();
|
||||
main.focus();
|
||||
}
|
||||
main?.webContents.send('video:popout-action', args);
|
||||
return {};
|
||||
},
|
||||
// Live-note handlers
|
||||
'live-note:run': async (_event, args) => {
|
||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, typ
|
|||
import path from "node:path";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startRunsWatcher, startSessionsWatcher, markSessionsIndexReady,
|
||||
startChannelsWatcher,
|
||||
startCodeSessionStatusWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
|
|
@ -25,8 +26,10 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j
|
|||
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initChannels } from "@x/core/dist/channels/service.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
|
||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
|
|
@ -35,6 +38,7 @@ import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event
|
|||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
|
||||
|
|
@ -44,6 +48,7 @@ import { execFileSync } from "node:child_process";
|
|||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import type { ISessions } from "@x/core/dist/sessions/index.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -56,6 +61,11 @@ import {
|
|||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
|
||||
// Captured as early as possible so it reflects actual process start. Used to
|
||||
// gate grace-eligible notifications (e.g. the burst of background-task
|
||||
// completions a reopen replays) — see ElectronNotificationService.
|
||||
const APP_LAUNCHED_AT = Date.now();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
|
|
@ -211,6 +221,38 @@ function configureSessionPermissions(targetSession: Session): void {
|
|||
});
|
||||
}
|
||||
|
||||
// Wire Ctrl/Cmd + (+ / − / 0) to zoom the renderer in/out/reset.
|
||||
// The app sets no application menu, so the default menu's zoom roles aren't
|
||||
// available — and on Linux the menu bar is suppressed by the frameless
|
||||
// `hiddenInset` title bar — so handle the accelerators directly here.
|
||||
// `event.preventDefault()` stops the keystroke from leaking into the editor.
|
||||
function setupZoomShortcuts(win: BrowserWindow) {
|
||||
const ZOOM_STEP = 0.5; // zoom-level units (factor = 1.2 ^ level, ~9.5% per step)
|
||||
const MIN_ZOOM_LEVEL = -3;
|
||||
const MAX_ZOOM_LEVEL = 3;
|
||||
const wc = win.webContents;
|
||||
|
||||
wc.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
// Cmd on macOS, Ctrl elsewhere.
|
||||
if (!(process.platform === "darwin" ? input.meta : input.control)) return;
|
||||
|
||||
// input.key is the produced character: "+"/"=" share a physical key (as do
|
||||
// "-"/"_"), and numpad +/- produce the same characters, so this covers both.
|
||||
const key = input.key;
|
||||
if (key === "+" || key === "=") {
|
||||
wc.setZoomLevel(Math.min(wc.getZoomLevel() + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
event.preventDefault();
|
||||
} else if (key === "-" || key === "_") {
|
||||
wc.setZoomLevel(Math.max(wc.getZoomLevel() - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
event.preventDefault();
|
||||
} else if (key === "0") {
|
||||
wc.setZoomLevel(0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
|
|
@ -287,6 +329,9 @@ function createWindow() {
|
|||
// The WebContentsView is created lazily on first `browser:setVisible`.
|
||||
browserViewManager.attach(win);
|
||||
|
||||
// Cmd/Ctrl + (+ / − / 0) zoom shortcuts for the renderer UI.
|
||||
setupZoomShortcuts(win);
|
||||
|
||||
if (app.isPackaged) {
|
||||
win.loadURL("app://-/index.html");
|
||||
} else {
|
||||
|
|
@ -330,7 +375,7 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
|
@ -347,6 +392,44 @@ app.whenReady().then(async () => {
|
|||
// start runs watcher
|
||||
startRunsWatcher();
|
||||
|
||||
// One-time: port legacy runs/*.jsonl into the new turn/session runtime.
|
||||
// Must run BEFORE the session index is built so migrated sessions are picked
|
||||
// up by the startup scan. Fully defensive — never blocks boot.
|
||||
try {
|
||||
const migration = migrateRuns();
|
||||
if (migration.scanned > 0) {
|
||||
console.log(
|
||||
`[runs-migration] migrated ${migration.migratedTurns} turn(s) across ` +
|
||||
`${migration.migratedSessions} session(s) from ${migration.scanned} run(s) ` +
|
||||
`(${migration.skipped} skipped, ${migration.failed.length} failed)`,
|
||||
);
|
||||
for (const failure of migration.failed) {
|
||||
console.warn(`[runs-migration] left in place (failed): ${failure.file} — ${failure.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[runs-migration] pass failed:', error);
|
||||
}
|
||||
|
||||
// New runtime: build the in-memory session index (startup scan), then
|
||||
// forward the session bus to windows. The renderer window is already up and
|
||||
// may have called sessions:list — that handler blocks on
|
||||
// markSessionsIndexReady, which must fire even if the scan throws so the
|
||||
// list never hangs.
|
||||
try {
|
||||
await container.resolve<ISessions>('sessions').initialize();
|
||||
} finally {
|
||||
markSessionsIndexReady();
|
||||
}
|
||||
startSessionsWatcher();
|
||||
|
||||
// Mobile channels (WhatsApp/Telegram bridge): needs the session index, so
|
||||
// start after initialize(). Failures must never block boot.
|
||||
startChannelsWatcher();
|
||||
initChannels().catch((error) => {
|
||||
console.error('[Channels] Failed to start mobile channels:', error);
|
||||
});
|
||||
|
||||
// start code-session status tracker (derives working/needs-you/idle + notifications)
|
||||
startCodeSessionStatusWatcher();
|
||||
|
||||
|
|
@ -410,6 +493,9 @@ app.whenReady().then(async () => {
|
|||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start meeting prep scheduler (generates prep notes ~6h before a meeting)
|
||||
void initMeetingPrep();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserWindow, Notification, shell } from "electron";
|
||||
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
|
||||
import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js";
|
||||
import { dispatchUrl } from "../deeplink.js";
|
||||
|
||||
const HTTP_URL = /^https?:\/\//i;
|
||||
|
|
@ -11,11 +12,27 @@ export class ElectronNotificationService implements INotificationService {
|
|||
// gets dropped and macOS clicks just focus the app silently.
|
||||
private active = new Set<Notification>();
|
||||
|
||||
// Timestamp the app launched, used to gate grace-eligible notifications (see
|
||||
// NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts
|
||||
// so it reflects process start rather than whenever the first notify fires.
|
||||
private readonly launchedAt: number;
|
||||
|
||||
constructor(launchedAt: number = Date.now()) {
|
||||
this.launchedAt = launchedAt;
|
||||
}
|
||||
|
||||
isSupported(): boolean {
|
||||
return Notification.isSupported();
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void {
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void {
|
||||
// Startup grace: a reopen replays every background task that completed
|
||||
// while the app was closed, so grace-eligible notifications fired in the
|
||||
// first moments after launch are dropped to avoid a notification flood.
|
||||
if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ambient notifications are suppressed while the app is in the
|
||||
// foreground — the user is already looking at it. A window counts as
|
||||
// foreground only if it's actually focused (minimized / other-space
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
|
|
@ -83,6 +85,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -91,9 +96,11 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
apps/x/apps/renderer/scripts/generate-tour-audio.mjs
Normal file
62
apps/x/apps/renderer/scripts/generate-tour-audio.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Regenerates the bundled product-tour narration clips.
|
||||
*
|
||||
* Parses the TOUR_STEPS texts straight out of product-tour.tsx (so the code
|
||||
* stays the single source of truth), synthesizes each one via @x/core's
|
||||
* synthesizeSpeech (Rowboat proxy when signed in, direct ElevenLabs otherwise,
|
||||
* using the voice id configured there / in ~/.rowboat/config/elevenlabs.json),
|
||||
* and writes MP3s to src/assets/tour/<step-id>.mp3.
|
||||
*
|
||||
* Run whenever a step's narration text or the tour voice changes:
|
||||
* cd apps/x && npm run deps # script imports core's built output
|
||||
* node apps/renderer/scripts/generate-tour-audio.mjs
|
||||
*
|
||||
* Pass step ids to regenerate only those clips (e.g. to re-roll one whose
|
||||
* synthesis came out glitchy):
|
||||
* node apps/renderer/scripts/generate-tour-audio.mjs welcome done
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const tourSource = path.join(here, '../src/components/product-tour.tsx')
|
||||
const outDir = path.join(here, '../src/assets/tour')
|
||||
const corePath = path.join(here, '../../../packages/core/dist/voice/voice.js')
|
||||
|
||||
const { synthesizeSpeech } = await import(corePath)
|
||||
|
||||
const src = await readFile(tourSource, 'utf8')
|
||||
const start = src.indexOf('const TOUR_STEPS')
|
||||
const end = src.indexOf('\n]', start)
|
||||
if (start === -1 || end === -1) throw new Error('Could not locate TOUR_STEPS in product-tour.tsx')
|
||||
const block = src.slice(start, end)
|
||||
|
||||
const steps = []
|
||||
// voiceText, when present, is the spoken variant of the bubble text.
|
||||
const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')(?:,\s*voiceText:\s*("[^"]*"|'[^']*'))?/g
|
||||
for (let m; (m = re.exec(block)); ) {
|
||||
// The captures are JS string literals from our own source; evaluate them
|
||||
// to resolve the quoting.
|
||||
steps.push({ id: m[1], text: new Function(`return ${m[3] ?? m[2]}`)() })
|
||||
}
|
||||
if (steps.length === 0) throw new Error('Parsed zero tour steps — regex out of sync with product-tour.tsx?')
|
||||
console.log(`Parsed ${steps.length} tour steps`)
|
||||
|
||||
const only = process.argv.slice(2)
|
||||
if (only.length > 0) {
|
||||
const unknown = only.filter((id) => !steps.some((s) => s.id === id))
|
||||
if (unknown.length > 0) throw new Error(`Unknown step ids: ${unknown.join(', ')}`)
|
||||
steps.splice(0, steps.length, ...steps.filter((s) => only.includes(s.id)))
|
||||
console.log(`Regenerating only: ${only.join(', ')}`)
|
||||
}
|
||||
|
||||
await mkdir(outDir, { recursive: true })
|
||||
for (const step of steps) {
|
||||
process.stdout.write(`synthesizing ${step.id}... `)
|
||||
const { audioBase64 } = await synthesizeSpeech(step.text)
|
||||
const file = path.join(outDir, `${step.id}.mp3`)
|
||||
await writeFile(file, Buffer.from(audioBase64, 'base64'))
|
||||
console.log(`${(audioBase64.length * 0.75 / 1024).toFixed(0)} KB`)
|
||||
}
|
||||
console.log(`Done — ${steps.length} clips in ${path.relative(process.cwd(), outDir)}`)
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
BIN
apps/x/apps/renderer/src/assets/tour/agents.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/agents.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/chats.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/chats.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/code.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/code.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/composer.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/composer.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/done.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/done.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/email.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/email.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/home.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/home.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/knowledge.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/knowledge.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/meetings.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/meetings.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/welcome.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/welcome.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/workspaces.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/workspaces.mp3
Normal file
Binary file not shown.
|
|
@ -43,6 +43,7 @@ export const Conversation = ({
|
|||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
const updateBottomState = useCallback(() => {
|
||||
|
|
@ -50,7 +51,9 @@ export const Conversation = ({
|
|||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX);
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
stickToBottomRef.current = atBottom;
|
||||
setIsAtBottom(atBottom);
|
||||
}, []);
|
||||
|
||||
const applyAnchorLayout = useCallback(
|
||||
|
|
@ -131,7 +134,12 @@ export const Conversation = ({
|
|||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const shouldStick = !anchorMessageId && stickToBottomRef.current;
|
||||
applyAnchorLayout(false);
|
||||
if (shouldStick) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateBottomState();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -178,6 +186,7 @@ export const Conversation = ({
|
|||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
stickToBottomRef.current = true;
|
||||
updateBottomState();
|
||||
}, [updateBottomState]);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
import { getToolActionsSummary, getToolDisplayName, getToolErrorText, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -329,7 +329,7 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
|||
<ToolTabbedContent
|
||||
input={tool.input as ToolUIPart["input"]}
|
||||
output={tool.result as ToolUIPart["output"]}
|
||||
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
||||
errorText={getToolErrorText(tool)}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import {
|
|||
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
|
||||
Code2, FolderOpen, LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import type { z } from 'zod'
|
||||
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -17,7 +15,7 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
|
|||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
|
@ -970,10 +968,9 @@ function SetupTab({
|
|||
// Runs history tab — list + drill-down transcript view
|
||||
//
|
||||
// Source of truth: `bg-tasks/<slug>/runs.log` — a plain-text file with one
|
||||
// runId per line (newest first). The actual transcripts live at the global
|
||||
// `$WorkDir/runs/<runId>.jsonl`, so this tab fetches runIds via the bg-task
|
||||
// IPC, then loads each Run through the standard `runs:fetch`. No bg-task-
|
||||
// specific transcript path or schema needed.
|
||||
// turn id per line (newest first). Transcripts live in the turn runtime's
|
||||
// storage; this tab fetches ids via the bg-task IPC, then loads each through
|
||||
// the shared agent-transcript loader (turn-first, legacy-run fallback).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunRowSummary {
|
||||
|
|
@ -984,27 +981,6 @@ interface RunRowSummary {
|
|||
error?: string
|
||||
}
|
||||
|
||||
// Pull the bits we want to display for a row out of a full Run's event log.
|
||||
function summarizeRun(run: z.infer<typeof Run>): RunRowSummary {
|
||||
const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase }
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) {
|
||||
const [rows, setRows] = useState<RunRowSummary[]>([])
|
||||
|
|
@ -1016,19 +992,25 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask })
|
|||
setLoading(true)
|
||||
try {
|
||||
const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 })
|
||||
// Fetch each Run in parallel via the canonical IPC. Runs whose
|
||||
// jsonl no longer exists (deleted manually, never written, …) are
|
||||
// dropped silently.
|
||||
// Fetch transcripts in parallel (turn-first, legacy-run
|
||||
// fallback). Ids whose files no longer exist keep a bare row so
|
||||
// the user knows the run happened.
|
||||
const settled = await Promise.allSettled(
|
||||
runIds.map(runId => window.ipc.invoke('runs:fetch', { runId }))
|
||||
runIds.map(runId => fetchAgentRunTranscript(runId))
|
||||
)
|
||||
const next: RunRowSummary[] = []
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const r = settled[i]
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
next.push(summarizeRun(r.value))
|
||||
if (r.status === 'fulfilled') {
|
||||
const t = r.value
|
||||
next.push({
|
||||
runId: t.id,
|
||||
...(t.createdAt === undefined ? {} : { createdAt: t.createdAt }),
|
||||
...(t.trigger === undefined ? {} : { trigger: t.trigger }),
|
||||
...(t.summary === undefined ? {} : { summary: t.summary }),
|
||||
...(t.error === undefined ? {} : { error: t.error }),
|
||||
})
|
||||
} else {
|
||||
// Keep the row visible with just the id so the user knows it exists.
|
||||
next.push({ runId: runIds[i] })
|
||||
}
|
||||
}
|
||||
|
|
@ -1134,7 +1116,7 @@ function RunTranscriptView({
|
|||
isInFlight: boolean
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -1144,15 +1126,13 @@ function RunTranscriptView({
|
|||
setError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
// Bg-task transcripts now live at the global runs/ location —
|
||||
// same path resolution as every other run, no special handling.
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
|
|
@ -1160,8 +1140,8 @@ function RunTranscriptView({
|
|||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
const summary = run ? summarizeRun(run) : undefined
|
||||
const items: ConversationItem[] = run ? runLogToConversation(run.log) : []
|
||||
const summary = transcript ?? undefined
|
||||
const items: ConversationItem[] = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
|
@ -1221,10 +1201,10 @@ function RunTranscriptView({
|
|||
Couldn't load transcript: {error}
|
||||
</div>
|
||||
)}
|
||||
{run && !loading && items.length === 0 && (
|
||||
{transcript && !loading && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loading && items.length > 0 && (
|
||||
{transcript && !loading && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
|
||||
|
||||
import type { HttpAuthRequest } from '@x/shared/dist/browser-control.js'
|
||||
|
||||
import { TabBar } from '@/components/tab-bar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
|
|
@ -86,9 +98,80 @@ const getBrowserTabTitle = (tab: BrowserTabState) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential prompt for HTTP basic/proxy auth challenges raised by pages in
|
||||
* the embedded browser. Rendered as a regular app dialog: BrowserPane already
|
||||
* hides the native WebContentsView whenever a dialog overlay is open, so the
|
||||
* prompt is never obscured by the page that triggered it.
|
||||
*/
|
||||
function BrowserHttpAuthDialog({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
request: HttpAuthRequest
|
||||
onSubmit: (username: string, password: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
// Basic auth allows an empty username (token-style `curl -u :TOKEN`), so the
|
||||
// only invalid submission is fully empty. The server decides the rest.
|
||||
const canSubmit = username.length > 0 || password.length > 0
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
onSubmit(username, password)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onCancel() }}>
|
||||
<DialogContent className="w-[min(24rem,calc(100%-2rem))] max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in</DialogTitle>
|
||||
<DialogDescription>
|
||||
{request.isProxy
|
||||
? `The proxy ${request.host} requires a username and password.`
|
||||
: `${request.host} requires a username and password.`}
|
||||
{request.realm ? ` (${request.realm})` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Input
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
Sign in
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
|
||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
const [authQueue, setAuthQueue] = useState<HttpAuthRequest[]>([])
|
||||
|
||||
const activeTabIdRef = useRef<string | null>(null)
|
||||
const addressFocusedRef = useRef(false)
|
||||
|
|
@ -121,6 +204,51 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
return cleanup
|
||||
}, [applyState])
|
||||
|
||||
// Mirror of authQueue for the unmount handler, which must read the latest
|
||||
// queue without re-subscribing on every change.
|
||||
const authQueueRef = useRef<HttpAuthRequest[]>([])
|
||||
useEffect(() => {
|
||||
authQueueRef.current = authQueue
|
||||
}, [authQueue])
|
||||
|
||||
useEffect(() => {
|
||||
const offRequest = window.ipc.on('browser:httpAuthRequest', (incoming) => {
|
||||
setAuthQueue((queue) => [...queue, incoming as HttpAuthRequest])
|
||||
})
|
||||
// Main resolved a challenge on its own (timeout, or its tab/window was
|
||||
// destroyed) — drop the corresponding dialog so it can't linger over an
|
||||
// unrelated page with a submit that would no-op.
|
||||
const offResolved = window.ipc.on('browser:httpAuthResolved', (incoming) => {
|
||||
const { requestId } = incoming as { requestId: string }
|
||||
setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
})
|
||||
return () => {
|
||||
offRequest()
|
||||
offResolved()
|
||||
// Cancel anything still pending so the main-process login callbacks and
|
||||
// timers are freed immediately instead of waiting out the timeout.
|
||||
for (const request of authQueueRef.current) {
|
||||
void window.ipc.invoke('browser:httpAuthResponse', { requestId: request.requestId })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const respondToAuth = useCallback(
|
||||
(requestId: string, credentials: { username: string; password: string } | null) => {
|
||||
setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
// Omit username to cancel; include it (even empty) to submit.
|
||||
void window.ipc.invoke(
|
||||
'browser:httpAuthResponse',
|
||||
credentials
|
||||
? { requestId, username: credentials.username, password: credentials.password }
|
||||
: { requestId },
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const activeAuthRequest = authQueue[0] ?? null
|
||||
|
||||
const setViewVisible = useCallback((visible: boolean) => {
|
||||
if (viewVisibleRef.current === visible) return
|
||||
viewVisibleRef.current = visible
|
||||
|
|
@ -420,6 +548,17 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
className="relative min-h-0 min-w-0 flex-1"
|
||||
data-browser-viewport
|
||||
/>
|
||||
|
||||
{activeAuthRequest && (
|
||||
<BrowserHttpAuthDialog
|
||||
key={activeAuthRequest.requestId}
|
||||
request={activeAuthRequest}
|
||||
onSubmit={(username, password) =>
|
||||
respondToAuth(activeAuthRequest.requestId, { username, password })
|
||||
}
|
||||
onCancel={() => respondToAuth(activeAuthRequest.requestId, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react'
|
||||
import { Bot, Mail, Sparkles, Telescope } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
export interface ChatEmptyStateRun {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
recentRuns?: ChatEmptyStateRun[]
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
/** Fill the composer with a starter prompt (does not submit). */
|
||||
onPickPrompt: (prompt: string) => void
|
||||
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
|
||||
|
|
@ -27,13 +17,10 @@ const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt
|
|||
]
|
||||
|
||||
/**
|
||||
* Empty-state body for the chat surface: greeting, recent chats, and starter
|
||||
* action cards. Shown in both the side-pane copilot and full-screen chat.
|
||||
* Empty-state body for the chat surface: greeting and starter action cards.
|
||||
* Shown in both the side-pane copilot and full-screen chat.
|
||||
*/
|
||||
export function ChatEmptyState({
|
||||
recentRuns = [],
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
onPickPrompt,
|
||||
wide = false,
|
||||
}: ChatEmptyStateProps) {
|
||||
|
|
@ -45,45 +32,13 @@ export function ChatEmptyState({
|
|||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or start with a suggestion.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentRuns.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="flex-1">Recent chats</span>
|
||||
{onOpenChatHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenChatHistory}
|
||||
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowUpRight className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{recentRuns.slice(0, 4).map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onSelectRun?.(run.id)}
|
||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
|
||||
Get started
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{SUGGESTED_ACTIONS.map((action) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
|
|
@ -15,16 +15,19 @@ import {
|
|||
FolderCog,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Headphones,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Lock,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Phone,
|
||||
PhoneOff,
|
||||
Plus,
|
||||
Presentation,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Terminal,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
|
|
@ -210,6 +213,18 @@ function compactWorkDirPath(path: string) {
|
|||
return path.replace(/^\/Users\/[^/]+/, '~')
|
||||
}
|
||||
|
||||
// Call presets: front doors into the same call engine, differing only in
|
||||
// starting devices. 'share' is the call button's main click — the "work
|
||||
// together" default (screen shared, camera off, floating pill). The chevron
|
||||
// menu holds the deviations.
|
||||
export type CallPreset = 'voice' | 'video' | 'share' | 'practice'
|
||||
|
||||
const CALL_PRESET_MENU: Array<{ preset: CallPreset; label: string; description: string; Icon: typeof Phone }> = [
|
||||
{ preset: 'voice', label: 'Voice call', description: 'Just talk — nothing is shared, the mascot hovers while you work', Icon: AudioLines },
|
||||
{ preset: 'video', label: 'Video call', description: 'Camera on, face to face — it sees your expressions', Icon: Video },
|
||||
{ preset: 'practice', label: 'Practice session', description: 'Rehearse a pitch or interview with live coaching', Icon: Presentation },
|
||||
]
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onStop?: () => void
|
||||
|
|
@ -223,18 +238,20 @@ interface ChatInputInnerProps {
|
|||
onDraftChange?: (text: string) => void
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||
/** Live mic amplitude history (RMS per frame) driving the recording waveform. */
|
||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onSubmitRecording?: () => void | Promise<void>
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
/** A call is live (hands-free voice loop + spoken responses). */
|
||||
inCall?: boolean
|
||||
/** Start a call with the given preset's device defaults. */
|
||||
onStartCall?: (preset: CallPreset) => void
|
||||
onEndCall?: () => void
|
||||
/** Calls need both voice input (STT) and voice output (TTS) configured. */
|
||||
callAvailable?: boolean
|
||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
/** Work directory for this chat (per-chat). Null when none is set. */
|
||||
|
|
@ -262,16 +279,16 @@ function ChatInputInner({
|
|||
onDraftChange,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
audioLevelsRef,
|
||||
onStartRecording,
|
||||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
inCall,
|
||||
onStartCall,
|
||||
onEndCall,
|
||||
callAvailable,
|
||||
onSelectedModelChange,
|
||||
workDir = null,
|
||||
onWorkDirChange,
|
||||
|
|
@ -286,6 +303,11 @@ function ChatInputInner({
|
|||
|
||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in the picker instead of guessing from
|
||||
// list order, which can disagree with the real default.
|
||||
const [defaultModel, setDefaultModel] = useState<ConfiguredModel | null>(null)
|
||||
const loadModelConfigEpoch = useRef(0)
|
||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
|
|
@ -341,22 +363,15 @@ function ChatInputInner({
|
|||
}
|
||||
})
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
// Sessions runtime: model and permission mode are per-message turn config,
|
||||
// so nothing is frozen for an existing chat — the picker stays live.
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setLockedModel(null)
|
||||
setPermissionMode('auto')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
||||
if (cancelled) return
|
||||
if (run.provider && run.model) {
|
||||
setLockedModel({ provider: run.provider, model: run.model })
|
||||
}
|
||||
setPermissionMode(run.permissionMode ?? 'manual')
|
||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||
return () => { cancelled = true }
|
||||
setLockedModel(null)
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -388,6 +403,17 @@ function ChatInputInner({
|
|||
// Load the list of models the user can choose from.
|
||||
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
// Concurrent runs race (mount fires one before the sign-in state resolves,
|
||||
// which fires another) — only the newest run may write state, else a slow
|
||||
// stale run can clobber the fresh list with an empty one.
|
||||
const epoch = ++loadModelConfigEpoch.current
|
||||
try {
|
||||
const def = await window.ipc.invoke('llm:getDefaultModel', null)
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setDefaultModel({ provider: def.provider as ProviderName, model: def.model })
|
||||
} catch {
|
||||
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
|
||||
}
|
||||
try {
|
||||
if (isRowboatConnected) {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
|
|
@ -397,28 +423,63 @@ function ChatInputInner({
|
|||
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
||||
(m: { id: string }) => ({ provider: 'rowboat', model: m.id })
|
||||
)
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setConfiguredModels(models)
|
||||
} else {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// Offer every model the configured key supports — the same full catalog
|
||||
// Settings uses for its dropdowns — so BYOK chat matches the signed-in
|
||||
// gateway picker. The picker is no longer limited to a hand-curated
|
||||
// config.models list. Providers with no catalog (Ollama, OpenAI-compatible)
|
||||
// fall back to the model saved in config.
|
||||
const catalog: Record<string, string[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
}
|
||||
} catch { /* offline / no catalog — fall back to saved config below */ }
|
||||
|
||||
const models: ConfiguredModel[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({ provider: flavor as ProviderName, model })
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const push = (provider: string, model: string) => {
|
||||
if (!model) return
|
||||
const key = `${provider}/${model}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
models.push({ provider: provider as ProviderName, model })
|
||||
}
|
||||
|
||||
// List the default provider first so its default model leads the picker.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const hasKey = typeof e.apiKey === 'string' && (e.apiKey as string).trim().length > 0
|
||||
const hasBaseURL = typeof e.baseURL === 'string' && (e.baseURL as string).trim().length > 0
|
||||
if (!hasKey && !hasBaseURL) continue // provider not configured
|
||||
|
||||
// The provider's saved default model leads, then the rest of its catalog.
|
||||
push(flavor, typeof e.model === 'string' ? e.model : '')
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(flavor, m)
|
||||
} else {
|
||||
// No catalog (local provider) — fall back to whatever is saved.
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(flavor, m)
|
||||
}
|
||||
}
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setConfiguredModels(models)
|
||||
}
|
||||
} catch {
|
||||
// No config yet
|
||||
} catch (err) {
|
||||
// No config yet — but surface unexpected failures for diagnosis.
|
||||
console.error('[chat-input] failed to load model list', err)
|
||||
}
|
||||
}, [isRowboatConnected])
|
||||
|
||||
|
|
@ -605,15 +666,25 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// The dropdown's items: always include the effective default so the picker
|
||||
// is never empty (and never missing the model that actually runs) even
|
||||
// while the full list is still loading.
|
||||
const pickerModels = useMemo<ConfiguredModel[]>(() => {
|
||||
if (!defaultModel) return configuredModels
|
||||
const defaultKey = `${defaultModel.provider}/${defaultModel.model}`
|
||||
if (configuredModels.some((m) => `${m.provider}/${m.model}` === defaultKey)) return configuredModels
|
||||
return [defaultModel, ...configuredModels]
|
||||
}, [configuredModels, defaultModel])
|
||||
|
||||
// Selecting a model affects only the *next* run created from this tab.
|
||||
// Once a run exists, model is frozen on the run and the dropdown is read-only.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
if (lockedModel) return
|
||||
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
|
||||
const entry = pickerModels.find((m) => `${m.provider}/${m.model}` === key)
|
||||
if (!entry) return
|
||||
setActiveModelKey(key)
|
||||
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
|
||||
}, [configuredModels, lockedModel, onSelectedModelChange])
|
||||
}, [pickerModels, lockedModel, onSelectedModelChange])
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
|
|
@ -726,7 +797,7 @@ function ChatInputInner({
|
|||
const currentWorkDirPath = effectiveWorkDir ? compactWorkDirPath(effectiveWorkDir) : ''
|
||||
|
||||
return (
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
<div data-tour-id="chat-composer" className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
|
|
@ -797,23 +868,33 @@ function ChatInputInner({
|
|||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Audio-reactive waveform only — the transcribed words are intentionally
|
||||
not shown while recording; they're still captured and submitted. */}
|
||||
<div className="flex flex-1 items-center overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||
<VoiceWaveform audioLevelsRef={audioLevelsRef} />
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-5 truncate text-sm leading-5',
|
||||
recordingText?.trim() ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{recordingText?.trim() || (recordingState === 'stopping' ? 'Finalizing...' : 'Listening...')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onSubmitRecording}
|
||||
disabled={!recordingText?.trim()}
|
||||
disabled={recordingState === 'stopping'}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
recordingText?.trim()
|
||||
recordingState !== 'stopping'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
{recordingState === 'stopping' ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1207,7 +1288,7 @@ function ChatInputInner({
|
|||
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : configuredModels.length > 0 ? (
|
||||
) : pickerModels.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -1215,14 +1296,22 @@ function ChatInputInner({
|
|||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||
{getSelectedModelDisplayName(
|
||||
pickerModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model
|
||||
|| defaultModel?.model
|
||||
|| pickerModels[0]?.model
|
||||
|| 'Model'
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
||||
{configuredModels.map((m) => {
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{pickerModels.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
|
|
@ -1235,48 +1324,66 @@ function ChatInputInner({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{onToggleTts && ttsAvailable && (
|
||||
{onStartCall && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTts}
|
||||
onClick={() => {
|
||||
if (inCall) {
|
||||
onEndCall?.()
|
||||
} else if (callAvailable) {
|
||||
onStartCall('share')
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
|
||||
ttsEnabled
|
||||
? 'text-foreground hover:bg-muted'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
|
||||
inCall
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: callAvailable
|
||||
? 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
: 'cursor-default text-muted-foreground/40'
|
||||
)}
|
||||
aria-label={ttsEnabled ? 'Disable voice output' : 'Enable voice output'}
|
||||
aria-label={inCall ? 'End call' : 'Start a call'}
|
||||
>
|
||||
<Headphones className="h-4 w-4" />
|
||||
{!ttsEnabled && (
|
||||
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{inCall ? <PhoneOff className="h-4 w-4" /> : <Phone className="h-4 w-4" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{ttsEnabled ? 'Voice output on' : 'Voice output off'}
|
||||
{inCall
|
||||
? 'End call'
|
||||
: callAvailable
|
||||
? 'Start a call — it sees your screen while you talk it through'
|
||||
: 'Calls need voice input and output configured'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{ttsEnabled && onTtsModeChange && (
|
||||
{!inCall && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Call options"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={ttsMode ?? 'summary'} onValueChange={(v) => onTtsModeChange(v as 'summary' | 'full')}>
|
||||
<DropdownMenuRadioItem value="summary">Speak summary</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="full">Speak full response</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
{CALL_PRESET_MENU.map(({ preset, label, description, Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
disabled={!callAvailable}
|
||||
onSelect={() => onStartCall(preset)}
|
||||
className="items-start gap-3 py-2"
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium leading-tight">{label}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
|
@ -1445,17 +1552,16 @@ export interface ChatInputWithMentionsProps {
|
|||
onDraftChange?: (text: string) => void
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onSubmitRecording?: () => void | Promise<void>
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
inCall?: boolean
|
||||
onStartCall?: (preset: CallPreset) => void
|
||||
onEndCall?: () => void
|
||||
callAvailable?: boolean
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
workDir?: string | null
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
|
|
@ -1485,11 +1591,10 @@ export function ChatInputWithMentions({
|
|||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
inCall,
|
||||
onStartCall,
|
||||
onEndCall,
|
||||
callAvailable,
|
||||
onSelectedModelChange,
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
|
|
@ -1516,11 +1621,10 @@ export function ChatInputWithMentions({
|
|||
onSubmitRecording={onSubmitRecording}
|
||||
onCancelRecording={onCancelRecording}
|
||||
voiceAvailable={voiceAvailable}
|
||||
ttsAvailable={ttsAvailable}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMode={ttsMode}
|
||||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
inCall={inCall}
|
||||
onStartCall={onStartCall}
|
||||
onEndCall={onEndCall}
|
||||
callAvailable={callAvailable}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
workDir={workDir}
|
||||
onWorkDirChange={onWorkDirChange}
|
||||
|
|
|
|||
|
|
@ -91,11 +91,28 @@ interface ChatMessageAttachmentsProps {
|
|||
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||
if (attachments.length === 0) return null
|
||||
|
||||
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
|
||||
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
|
||||
const videoFrames = attachments.filter((attachment) => attachment.isVideoFrame)
|
||||
const imageAttachments = attachments.filter(
|
||||
(attachment) => !attachment.isVideoFrame && isImageMime(attachment.mimeType)
|
||||
)
|
||||
const fileAttachments = attachments.filter(
|
||||
(attachment) => !attachment.isVideoFrame && !isImageMime(attachment.mimeType)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||
{videoFrames.length > 0 && (
|
||||
<div className="flex max-w-[340px] flex-wrap justify-end gap-1">
|
||||
{videoFrames.map((frame, index) => (
|
||||
<img
|
||||
key={`frame-${index}`}
|
||||
src={frame.thumbnailUrl}
|
||||
alt={`Camera frame ${index + 1}`}
|
||||
className="h-12 w-auto rounded-md border border-border/60 bg-muted object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
|||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
getToolErrorText,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
|
|
@ -142,6 +143,10 @@ interface ChatSidebarProps {
|
|||
chatTabStates?: Record<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
isProcessing: boolean
|
||||
// Actively working (sessions runtime). When provided, drives the shimmer
|
||||
// instead of isProcessing so waiting on a permission/ask-human doesn't
|
||||
// render a "Thinking…" under the card.
|
||||
isThinking?: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
|
|
@ -177,17 +182,16 @@ interface ChatSidebarProps {
|
|||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onSubmitRecording?: () => void | Promise<void>
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
inCall?: boolean
|
||||
onStartCall?: (preset: CallPreset) => void
|
||||
onEndCall?: () => void
|
||||
callAvailable?: boolean
|
||||
onComposioConnected?: (toolkitSlug: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +215,7 @@ export function ChatSidebar({
|
|||
chatTabStates = {},
|
||||
viewportAnchors = {},
|
||||
isProcessing,
|
||||
isThinking,
|
||||
isStopping,
|
||||
onStop,
|
||||
onSubmit,
|
||||
|
|
@ -246,11 +251,10 @@ export function ChatSidebar({
|
|||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
inCall,
|
||||
onStartCall,
|
||||
onEndCall,
|
||||
callAvailable,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
|
|
@ -373,7 +377,14 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
// Session-first (new runtime); legacy runs fallback covers old
|
||||
// background tabs until stage 7 removes the runs runtime.
|
||||
let result: { success: boolean; error?: string }
|
||||
try {
|
||||
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
|
||||
} catch {
|
||||
result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success('Chat log saved')
|
||||
} else if (result.error) {
|
||||
|
|
@ -474,7 +485,7 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const errorText = getToolErrorText(item)
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
return (
|
||||
|
|
@ -674,11 +685,6 @@ export function ChatSidebar({
|
|||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
// A pinned coding-session chat must not offer jumping
|
||||
// to other conversations from the empty state either.
|
||||
recentRuns={pinnedToCodeSession ? [] : recentRuns}
|
||||
onSelectRun={pinnedToCodeSession ? undefined : onSelectRun}
|
||||
onOpenChatHistory={pinnedToCodeSession ? undefined : onOpenChatHistory}
|
||||
onPickPrompt={setLocalPresetMessage}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -730,7 +736,7 @@ export function ChatSidebar({
|
|||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && (isThinking ?? isProcessing)}
|
||||
response={response}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -747,7 +753,7 @@ export function ChatSidebar({
|
|||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && (isThinking ?? isProcessing)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -759,7 +765,7 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
{isActive && (isThinking ?? isProcessing) && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
|
|
@ -818,11 +824,10 @@ export function ChatSidebar({
|
|||
onSubmitRecording={isActive ? onSubmitRecording : undefined}
|
||||
onCancelRecording={isActive ? onCancelRecording : undefined}
|
||||
voiceAvailable={isActive && voiceAvailable}
|
||||
ttsAvailable={isActive && ttsAvailable}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMode={ttsMode}
|
||||
onToggleTts={isActive ? onToggleTts : undefined}
|
||||
onTtsModeChange={isActive ? onTtsModeChange : undefined}
|
||||
inCall={inCall}
|
||||
onStartCall={isActive ? onStartCall : undefined}
|
||||
onEndCall={isActive ? onEndCall : undefined}
|
||||
callAvailable={callAvailable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ const darkHighlight = HighlightStyle.define([
|
|||
])
|
||||
|
||||
export function cmBaseExtensions(isDark: boolean): Extension[] {
|
||||
const bg = isDark ? '#0f1117' : '#ffffff'
|
||||
const panelBg = isDark ? '#151821' : '#f6f8fa'
|
||||
const text = isDark ? '#d4d4d8' : '#24292f'
|
||||
const muted = isDark ? '#7d8590' : '#6e7781'
|
||||
const border = isDark ? '#2f3542' : '#d0d7de'
|
||||
|
||||
return [
|
||||
lineNumbers(),
|
||||
bracketMatching(),
|
||||
|
|
@ -39,18 +45,62 @@ export function cmBaseExtensions(isDark: boolean): Extension[] {
|
|||
EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
fontSize: '12px',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-editor': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: text,
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-line': {
|
||||
color: text,
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: isDark ? '#6b7280' : '#9ca3af',
|
||||
backgroundColor: panelBg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: muted,
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)',
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: isDark ? 'rgba(88, 166, 255, 0.32)' : 'rgba(9, 105, 218, 0.22)',
|
||||
},
|
||||
'.cm-panels, .cm-panel': {
|
||||
backgroundColor: panelBg,
|
||||
color: text,
|
||||
borderColor: border,
|
||||
},
|
||||
'.cm-mergeView': {
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
},
|
||||
'.cm-mergeViewEditors': {
|
||||
backgroundColor: bg,
|
||||
},
|
||||
'.cm-mergeView .cm-editor': {
|
||||
borderColor: border,
|
||||
},
|
||||
'.cm-changedLine': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.14)' : 'rgba(9, 105, 218, 0.08)',
|
||||
},
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: isDark ? 'rgba(248, 81, 73, 0.14)' : 'rgba(255, 235, 233, 0.95)',
|
||||
},
|
||||
'.cm-insertedLine, .cm-insertedChunk': {
|
||||
backgroundColor: isDark ? 'rgba(63, 185, 80, 0.14)' : 'rgba(234, 255, 234, 0.95)',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
// GitHub-style expander bar for folded unchanged regions (@codemirror/merge).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type DragEvent, type MutableRefObject } from 'react'
|
||||
import { ArrowUp, FileText, Loader2, LoaderIcon, Mic, Plus, Square, Terminal, X } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -10,14 +10,59 @@ import { Conversation, ConversationContent, ConversationScrollButton } from '@/c
|
|||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, getToolDisplayName, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation'
|
||||
import { toToolState, getToolDisplayName, getToolErrorText, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation'
|
||||
import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding-run'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||
import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat'
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
const WAVE_BAR_WIDTH = 3
|
||||
const WAVE_BAR_GAP = 2
|
||||
const WAVE_BAR_MIN = 1.5
|
||||
const WAVE_BAR_MAX = 18
|
||||
|
||||
function VoiceWaveform({ audioLevelsRef }: { audioLevelsRef: MutableRefObject<number[]> }) {
|
||||
const [bars, setBars] = useState<number[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let lastSig = ''
|
||||
const tick = () => {
|
||||
const levels = audioLevelsRef.current
|
||||
const next = levels.length > 48 ? levels.slice(levels.length - 48) : levels
|
||||
const sig = `${next.length}:${next.length ? next[next.length - 1] : 0}`
|
||||
if (sig !== lastSig) {
|
||||
lastSig = sig
|
||||
setBars(next.slice())
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [audioLevelsRef])
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-full items-center overflow-hidden" style={{ gap: WAVE_BAR_GAP }}>
|
||||
{bars.map((level, i) => {
|
||||
const amp = Math.min(1, Math.max(0, level)) ** 0.8
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="shrink-0 rounded-full bg-primary"
|
||||
style={{
|
||||
width: WAVE_BAR_WIDTH,
|
||||
height: WAVE_BAR_MIN + amp * (WAVE_BAR_MAX - WAVE_BAR_MIN),
|
||||
transition: 'height 90ms linear',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -39,7 +84,7 @@ function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (pa
|
|||
<Tool open={open || item.status === 'running'} onOpenChange={setOpen}>
|
||||
<ToolHeader title={AGENT_LABEL[agent ?? ''] ?? 'Coding agent'} type="tool-code_agent_run" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} onOpenDiff={onOpenDiff} />
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} error={getToolErrorText(item)} onOpenDiff={onOpenDiff} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -97,20 +142,30 @@ export function CodeChat({
|
|||
session,
|
||||
status,
|
||||
onOpenDiff,
|
||||
voiceAvailable = false,
|
||||
}: {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
onOpenDiff: (path: string) => void
|
||||
voiceAvailable?: boolean
|
||||
}) {
|
||||
const {
|
||||
items, liveText, isProcessing, pendingPermission, pendingToolPermissions, pendingAskHumans,
|
||||
items, liveText, isProcessing, compactionStatus, contextUsage,
|
||||
pendingPermission, pendingToolPermissions, pendingAskHumans,
|
||||
loading, send, stop, resolvePermission, respondToToolPermission, respondToAskHuman,
|
||||
} = useCodeChat(session)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [stopping, setStopping] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const voice = useVoiceMode()
|
||||
const voiceWarmup = voice.warmup
|
||||
|
||||
const busy = isProcessing || status === 'working' || status === 'needs-you'
|
||||
const recording = voice.state !== 'idle'
|
||||
const recordingStopping = voice.state === 'submitting'
|
||||
const contextUsedPercent = contextUsage
|
||||
? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100))
|
||||
: null
|
||||
// Attached file PATHS — like dragging a file into the Claude Code CLI, the
|
||||
// agent receives paths and reads the files itself with its own tools.
|
||||
const [attachments, setAttachments] = useState<string[]>([])
|
||||
|
|
@ -119,6 +174,7 @@ export function CodeChat({
|
|||
setDraft('')
|
||||
setAttachments([])
|
||||
setStopping(false)
|
||||
voice.cancel()
|
||||
textareaRef.current?.focus()
|
||||
}, [session.id])
|
||||
|
||||
|
|
@ -126,6 +182,10 @@ export function CodeChat({
|
|||
if (!busy) setStopping(false)
|
||||
}, [busy])
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceAvailable) voiceWarmup()
|
||||
}, [voiceAvailable, voiceWarmup])
|
||||
|
||||
const addAttachments = (paths: string[]) => {
|
||||
const cleaned = paths.filter(Boolean)
|
||||
if (cleaned.length === 0) return
|
||||
|
|
@ -141,7 +201,7 @@ export function CodeChat({
|
|||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
if (!e.dataTransfer?.files?.length) return
|
||||
e.preventDefault()
|
||||
const paths = Array.from(e.dataTransfer.files)
|
||||
|
|
@ -175,6 +235,27 @@ export function CodeChat({
|
|||
await stop()
|
||||
}
|
||||
|
||||
const handleStartRecording = () => {
|
||||
if (busy) return
|
||||
void voice.start()
|
||||
}
|
||||
|
||||
const handleSubmitRecording = async () => {
|
||||
if (!recording || recordingStopping) return
|
||||
const text = await voice.submit()
|
||||
if (!text) return
|
||||
const result = await send(text)
|
||||
if (!result.ok && result.error) {
|
||||
toast.error(result.error)
|
||||
setDraft(text)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRecording = () => {
|
||||
voice.cancel()
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||
|
||||
return (
|
||||
|
|
@ -188,7 +269,10 @@ export function CodeChat({
|
|||
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{session.title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{AGENT_LABEL[session.agent]} — direct</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{AGENT_LABEL[session.agent]} — direct
|
||||
{contextUsedPercent != null ? ` · ${contextUsedPercent}% context used` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -239,9 +323,19 @@ export function CodeChat({
|
|||
/>
|
||||
))}
|
||||
{busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && (
|
||||
<Shimmer className="text-sm">
|
||||
{stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`}
|
||||
</Shimmer>
|
||||
compactionStatus === 'stalled' ? (
|
||||
<div className="text-sm text-amber-600">
|
||||
Context compaction is taking longer than expected. You can stop and retry in a fresh session.
|
||||
</div>
|
||||
) : (
|
||||
<Shimmer className="text-sm">
|
||||
{stopping
|
||||
? 'Stopping…'
|
||||
: compactionStatus === 'running'
|
||||
? 'Compacting context…'
|
||||
: `${AGENT_LABEL[session.agent]} is working…`}
|
||||
</Shimmer>
|
||||
)
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
|
|
@ -249,8 +343,8 @@ export function CodeChat({
|
|||
|
||||
{/* Composer — mirrors the assistant chat input's look (rounded card,
|
||||
borderless textarea, round primary send / destructive stop). */}
|
||||
<div className="p-3">
|
||||
<div className="rowboat-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
|
||||
<div className="bg-background p-3 dark:bg-black">
|
||||
<div className="rowboat-chat-input rowboat-code-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((p) => (
|
||||
|
|
@ -273,22 +367,58 @@ export function CodeChat({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 pb-2 pt-4">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
className="max-h-40 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm shadow-none outline-none focus-visible:ring-0"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{recording ? (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelRecording}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Cancel recording"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||
<VoiceWaveform audioLevelsRef={voice.audioLevelsRef} />
|
||||
<div className={cn('min-h-5 truncate text-sm leading-5', voice.interimText.trim() ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{voice.interimText.trim() || (recordingStopping ? 'Finalizing...' : 'Listening...')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => void handleSubmitRecording()}
|
||||
disabled={recordingStopping}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
recordingStopping
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
)}
|
||||
>
|
||||
{recordingStopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-2 pt-4">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
className="max-h-40 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm shadow-none outline-none focus-visible:ring-0"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -303,6 +433,22 @@ export function CodeChat({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Attach files — the agent reads them from disk (or drag & drop)</TooltipContent>
|
||||
</Tooltip>
|
||||
{voiceAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartRecording}
|
||||
disabled={busy || recording}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Voice input"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Voice input</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Terminal className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Direct — straight to {AGENT_LABEL[session.agent]}</span>
|
||||
|
|
|
|||
|
|
@ -338,12 +338,14 @@ export function CodeView({
|
|||
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
|
||||
</button>
|
||||
{terminalOpen && (
|
||||
<div style={{ height: terminalHeight }}>
|
||||
<TerminalPane
|
||||
key={selectedSession.id}
|
||||
terminalId={selectedSession.id}
|
||||
cwd={selectedSession.cwd}
|
||||
/>
|
||||
<div className="bg-background pb-3 dark:bg-black" style={{ height: terminalHeight + 12 }}>
|
||||
<div className="h-full min-h-0">
|
||||
<TerminalPane
|
||||
key={selectedSession.id}
|
||||
terminalId={selectedSession.id}
|
||||
cwd={selectedSession.cwd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ export function NewSessionDialog({
|
|||
}) {
|
||||
const [agentStatus, setAgentStatus] = useState<{ claude: AgentStatus; codex: AgentStatus } | null>(null)
|
||||
const [agent, setAgent] = useState<CodingAgent>('claude')
|
||||
// Rowboat drives by default — direct CLI access is the power-user opt-in.
|
||||
const [mode, setMode] = useState<CodeSessionMode>('rowboat')
|
||||
// Direct drive by default; Rowboat orchestration remains an opt-in per session.
|
||||
const [mode, setMode] = useState<CodeSessionMode>('direct')
|
||||
const [policy, setPolicy] = useState<ApprovalPolicy>('auto-approve-reads')
|
||||
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
|
||||
const [title, setTitle] = useState('')
|
||||
|
|
@ -101,7 +101,7 @@ export function NewSessionDialog({
|
|||
setTitle('')
|
||||
setCreating(false)
|
||||
setIsolation('in-repo')
|
||||
setMode('rowboat')
|
||||
setMode('direct')
|
||||
setModelKey('default')
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/contexts/theme-context'
|
|||
|
||||
// xterm color schemes tuned to the app's light/dark backgrounds.
|
||||
const DARK_THEME = {
|
||||
background: '#1b1b1f',
|
||||
background: '#000000',
|
||||
foreground: '#d4d4d8',
|
||||
cursor: '#d4d4d8',
|
||||
selectionBackground: 'rgba(120, 140, 255, 0.3)',
|
||||
|
|
@ -106,5 +106,11 @@ export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: str
|
|||
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||
}, [resolvedTheme])
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-hidden px-2 pt-1" />
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full overflow-hidden px-2 pt-1"
|
||||
style={{ backgroundColor: resolvedTheme === 'dark' ? '#000000' : '#ffffff' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ export interface PendingCodePermission {
|
|||
|
||||
const DIRECT_PREFIX = 'direct-'
|
||||
const STRUCTURAL_EVENTS = new Set(['tool_call', 'tool_call_update', 'plan', 'permission'])
|
||||
const COMPACTION_TITLE = 'Compacting context'
|
||||
const COMPACTION_STALLED_MS = 90_000
|
||||
|
||||
export type CompactionStatus = 'idle' | 'running' | 'stalled'
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
|
|
@ -62,6 +66,8 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
const [items, setItems] = useState<CodeChatItem[]>([])
|
||||
const [liveText, setLiveText] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [compactionStatus, setCompactionStatus] = useState<CompactionStatus>('idle')
|
||||
const [contextUsage, setContextUsage] = useState<{ used: number; size: number } | null>(null)
|
||||
const [pendingPermission, setPendingPermission] = useState<PendingCodePermission | null>(null)
|
||||
// Rowboat-mode copilot gates, same as the main chat: pre-tool-call permission
|
||||
// requests and ask-human questions. Keyed by toolCallId.
|
||||
|
|
@ -69,6 +75,7 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
const [pendingAskHumans, setPendingAskHumans] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const seenMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
const compactionToolIdRef = useRef<string | null>(null)
|
||||
|
||||
const applyCodeRunEvent = useCallback((toolCallId: string, event: CodeRunEvent) => {
|
||||
if (toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
|
|
@ -99,6 +106,9 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
if (!sessionId) {
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setCompactionStatus('idle')
|
||||
setContextUsage(null)
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
return
|
||||
}
|
||||
|
|
@ -106,6 +116,9 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
setLoading(true)
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setCompactionStatus('idle')
|
||||
setContextUsage(null)
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
setPendingToolPermissions(new Map())
|
||||
setPendingAskHumans(new Map())
|
||||
|
|
@ -217,6 +230,12 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
return () => { cancelled = true }
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (compactionStatus !== 'running') return
|
||||
const timer = window.setTimeout(() => setCompactionStatus('stalled'), COMPACTION_STALLED_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [compactionStatus])
|
||||
|
||||
// Live event stream.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
|
|
@ -230,6 +249,8 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
break
|
||||
case 'run-processing-end':
|
||||
setIsProcessing(false)
|
||||
setCompactionStatus('idle')
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
// Anything still streaming that never landed as a message (e.g. the
|
||||
// turn errored) is flushed so the text isn't lost.
|
||||
|
|
@ -247,6 +268,8 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
break
|
||||
case 'run-stopped':
|
||||
setIsProcessing(false)
|
||||
setCompactionStatus('idle')
|
||||
compactionToolIdRef.current = null
|
||||
setPendingPermission(null)
|
||||
setPendingToolPermissions(new Map())
|
||||
setPendingAskHumans(new Map())
|
||||
|
|
@ -348,6 +371,19 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
break
|
||||
case 'code-run-event': {
|
||||
setIsProcessing(true)
|
||||
if (event.event.type === 'usage') {
|
||||
setContextUsage({ used: event.event.used, size: event.event.size })
|
||||
}
|
||||
if (event.event.type === 'tool_call' && event.event.title === COMPACTION_TITLE) {
|
||||
compactionToolIdRef.current = event.event.id ?? null
|
||||
setCompactionStatus('running')
|
||||
}
|
||||
if (event.event.type === 'tool_call_update'
|
||||
&& event.event.id != null
|
||||
&& event.event.id === compactionToolIdRef.current) {
|
||||
compactionToolIdRef.current = null
|
||||
setCompactionStatus('idle')
|
||||
}
|
||||
if (event.event.type === 'message' && event.event.role === 'agent' && event.toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
const text = event.event.text
|
||||
setLiveText((prev) => prev + text)
|
||||
|
|
@ -460,6 +496,8 @@ export function useCodeChat(session: CodeSession | null) {
|
|||
items,
|
||||
liveText,
|
||||
isProcessing,
|
||||
compactionStatus,
|
||||
contextUsage,
|
||||
pendingPermission,
|
||||
pendingToolPermissions,
|
||||
pendingAskHumans,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleDot,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
Pencil,
|
||||
Search,
|
||||
ShieldQuestion,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Wrench,
|
||||
|
|
@ -16,7 +18,7 @@ import {
|
|||
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
import { getToolErrorText, toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
|
||||
// ── Timeline reduction ──────────────────────────────────────────────
|
||||
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
|
||||
|
|
@ -87,7 +89,8 @@ export function reduceEvents(events: CodeRunEvent[]): Row[] {
|
|||
return rows
|
||||
}
|
||||
|
||||
function toolKindIcon(kind?: string) {
|
||||
function toolKindIcon(kind?: string, title?: string) {
|
||||
if (title === 'Compacting context') return <Sparkles className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
switch (kind) {
|
||||
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
|
|
@ -109,14 +112,26 @@ const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
|||
|
||||
export function CodingRunTimeline({
|
||||
events,
|
||||
error,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
events: CodeRunEvent[]
|
||||
error?: string
|
||||
// When set, changed-file names become clickable (the Code section opens the diff).
|
||||
onOpenDiff?: (path: string) => void
|
||||
}) {
|
||||
const rows = useMemo(() => reduceEvents(events), [events])
|
||||
if (rows.length === 0) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent…</div>
|
||||
}
|
||||
return (
|
||||
|
|
@ -131,13 +146,18 @@ export function CodingRunTimeline({
|
|||
}
|
||||
if (row.kind === 'tool') {
|
||||
const running = row.status !== 'completed' && row.status !== 'failed'
|
||||
const failed = row.status === 'failed'
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{running
|
||||
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
|
||||
{toolKindIcon(row.toolKind)}
|
||||
{running ? (
|
||||
<Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : failed ? (
|
||||
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
|
||||
)}
|
||||
{toolKindIcon(row.toolKind, row.title)}
|
||||
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
|
||||
</div>
|
||||
{row.diffs.length > 0 && (
|
||||
|
|
@ -187,6 +207,12 @@ export function CodingRunTimeline({
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -256,12 +282,13 @@ export function CodingRunBlock({
|
|||
(item.result as { agent?: string } | undefined)?.agent ??
|
||||
(item.input as { agent?: string } | undefined)?.agent
|
||||
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
|
||||
const error = getToolErrorText(item)
|
||||
return (
|
||||
<>
|
||||
<Tool open={open} onOpenChange={onOpenChange}>
|
||||
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} />
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} error={error} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
{item.pendingCodePermission && (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isErrorMessage,
|
||||
isToolCall,
|
||||
getToolDisplayName,
|
||||
getToolErrorText,
|
||||
toToolState,
|
||||
normalizeToolOutput,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
|
@ -62,7 +63,7 @@ function CompactToolRow({ tool }: { tool: ToolCall }) {
|
|||
const [open, setOpen] = useState(false)
|
||||
const title = getToolDisplayName(tool)
|
||||
const state = toToolState(tool.status)
|
||||
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
|
||||
const errorText = getToolErrorText(tool)
|
||||
return (
|
||||
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
|
||||
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,11 +11,9 @@ import {
|
|||
ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import type z from 'zod'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
|
||||
export type OpenLiveNotePanelDetail = {
|
||||
|
|
@ -661,7 +659,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
function LastRunTab({ live }: { live: LiveNote }) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loadingRun, setLoadingRun] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -669,7 +667,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
setFetchError(null)
|
||||
setLoadingRun(false)
|
||||
return
|
||||
|
|
@ -679,13 +677,13 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
setFetchError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setFetchError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoadingRun(false)
|
||||
}
|
||||
|
|
@ -704,7 +702,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
}
|
||||
|
||||
const isError = !!live.lastRunError
|
||||
const items = run ? runLogToConversation(run.log) : []
|
||||
const items = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
|
||||
|
|
@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
Couldn't load transcript: {fetchError}
|
||||
</div>
|
||||
)}
|
||||
{run && !loadingRun && items.length === 0 && (
|
||||
{transcript && !loadingRun && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loadingRun && items.length > 0 && (
|
||||
{transcript && !loadingRun && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings'
|
|||
const CALENDAR_DIR = 'calendar_sync'
|
||||
const UPCOMING_MAX_DAYS = 4 // today + next 3
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingMeetingPrepCreate?: { prompt: string }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the `meeting-prep:resolve` IPC response shape.
|
||||
type PrepNote = {
|
||||
path: string
|
||||
name: string
|
||||
role?: string
|
||||
organization?: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepAttendee = {
|
||||
label: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
note: PrepNote | null
|
||||
}
|
||||
type PrepOrg = {
|
||||
path: string
|
||||
name: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepResult = {
|
||||
attendees: PrepAttendee[]
|
||||
organizations: PrepOrg[]
|
||||
prepNote: { path: string; brief: string } | null
|
||||
matchedCount: number
|
||||
unmatchedCount: number
|
||||
}
|
||||
|
||||
type MeetingNoteRow = {
|
||||
path: string
|
||||
name: string
|
||||
|
|
@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] {
|
|||
}).filter((part) => part.text.length > 0)
|
||||
}
|
||||
|
||||
function UpcomingEvents() {
|
||||
// Hand the unmatched attendee off to the Copilot to research + create a note.
|
||||
function requestCreateNote(attendee: PrepAttendee, meetingSummary: string) {
|
||||
const who = attendee.displayName || attendee.label
|
||||
const email = attendee.email ? ` <${attendee.email}>` : ''
|
||||
window.__pendingMeetingPrepCreate = {
|
||||
prompt: `Create a person note in my knowledge base for ${who}${email}. They're attending my "${meetingSummary}" meeting. Pull together what you know about them from my emails, past meetings, and calendar.`,
|
||||
}
|
||||
window.dispatchEvent(new Event('meeting-prep:create-note'))
|
||||
}
|
||||
|
||||
// One note row (used for both people and organizations): a clickable row that
|
||||
// navigates to the note. The markdown is NOT rendered inline in the card.
|
||||
function PrepNoteRow({ title, subtitle, path, onOpenNote }: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
path: string
|
||||
onOpenNote: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(path)}
|
||||
title={`Open ${title}`}
|
||||
className="flex w-full items-center gap-3 border-b px-5 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[12.5px] font-semibold text-foreground">{title}</span>
|
||||
{subtitle ? <span className="truncate text-[11px] text-muted-foreground">{subtitle}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PrepAttendeeNote({ attendee, onOpenNote }: { attendee: PrepAttendee; onOpenNote: (path: string) => void }) {
|
||||
const note = attendee.note
|
||||
if (!note) return null
|
||||
const subtitle = [note.role, note.organization].filter(Boolean).join(' · ')
|
||||
return <PrepNoteRow title={note.name} subtitle={subtitle || undefined} path={note.path} onOpenNote={onOpenNote} />
|
||||
}
|
||||
|
||||
function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (attendees.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{open ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{attendees.length} {attendees.length === 1 ? 'other' : 'others'} — no notes yet
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="flex flex-col gap-1 px-5 pb-3 pl-12">
|
||||
{attendees.map((att, idx) => (
|
||||
<div key={`${att.email ?? att.label}-${idx}`} className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="min-w-0 truncate text-sm text-foreground">{att.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestCreateNote(att, meetingSummary)}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<UserPlus className="size-3.5" />
|
||||
Create note
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline prep for a single event: resolves the attendees against the knowledge
|
||||
// base and renders their notes directly beneath the event row. Re-resolves when
|
||||
// a person note changes (e.g. after "Create note") so it stays fresh.
|
||||
function InlineMeetingPrep({ event, onOpenNote }: { event: UpcomingEvent; onOpenNote: (path: string) => void }) {
|
||||
const [prep, setPrep] = useState<PrepResult | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
const attendees = event.attendees.map((a) => ({ email: a.email, displayName: a.displayName, self: a.self }))
|
||||
const result = await window.ipc.invoke('meeting-prep:resolve', { attendees, eventId: event.id })
|
||||
if (!cancelled) setPrep(result)
|
||||
} catch (err) {
|
||||
console.error('Meeting prep failed:', err)
|
||||
if (!cancelled) setPrep(null)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [event.id, refreshTick])
|
||||
|
||||
// Refresh when a People note is created/changed so newly-created notes appear.
|
||||
useEffect(() => {
|
||||
const isPeoplePath = (p: string | undefined) =>
|
||||
typeof p === 'string' && p.startsWith('knowledge/People/')
|
||||
const cleanup = window.ipc.on('workspace:didChange', (e) => {
|
||||
switch (e.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isPeoplePath(e.path)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'moved':
|
||||
if (isPeoplePath(e.from) || isPeoplePath(e.to)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!e.paths || e.paths.some(isPeoplePath)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
if (!prep || prep.attendees.length === 0) return null
|
||||
|
||||
const matched = prep.attendees.filter((a) => a.note)
|
||||
const unmatched = prep.attendees.filter((a) => !a.note)
|
||||
|
||||
return (
|
||||
<div className="bg-muted/10">
|
||||
{prep.prepNote && prep.prepNote.brief ? (
|
||||
<div className="border-b px-5 pb-3 pt-3">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 [&_p]:text-[12.5px] [&_li]:text-[12.5px]">
|
||||
{prep.prepNote.brief}
|
||||
</Streamdown>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(prep.prepNote!.path)}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
Open full prep
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1.5 px-5 pb-1 pt-2.5">
|
||||
<UsersRound className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">People</span>
|
||||
</div>
|
||||
{matched.map((att, idx) => (
|
||||
<PrepAttendeeNote key={att.note!.path + idx} attendee={att} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
<PrepUnmatchedSection attendees={unmatched} meetingSummary={event.summary} />
|
||||
{prep.organizations.length > 0 ? (
|
||||
<>
|
||||
<div className="px-5 pb-1 pt-2.5">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prep.organizations.length === 1 ? 'Company' : 'Companies'}
|
||||
</span>
|
||||
</div>
|
||||
{prep.organizations.map((org) => (
|
||||
<PrepNoteRow key={org.path} title={org.name} subtitle="Organization" path={org.path} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) {
|
||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -487,6 +692,20 @@ function UpcomingEvents() {
|
|||
return selectVisibleDays(window)
|
||||
}, [events])
|
||||
|
||||
// The next meeting that's worth prepping for — soonest timed event with at
|
||||
// least one other attendee that hasn't ended. Its row gets inline prep.
|
||||
// `events` is sorted (all-day first, then by start), so `find` returns it.
|
||||
const prepEventId = useMemo(() => {
|
||||
const nowMs = Date.now()
|
||||
const candidate = events.find((ev) => {
|
||||
if (ev.isAllDay) return false
|
||||
if (ev.attendees.every((a) => a.self)) return false
|
||||
const endMs = ev.end ? ev.end.getTime() : ev.start.getTime() + 30 * 60 * 1000
|
||||
return endMs > nowMs
|
||||
})
|
||||
return candidate?.id ?? null
|
||||
}, [events])
|
||||
|
||||
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
|
||||
const now = new Date()
|
||||
const todayKey = localDateKey(now)
|
||||
|
|
@ -532,6 +751,8 @@ function UpcomingEvents() {
|
|||
key={day.dateKey}
|
||||
day={day}
|
||||
isToday={day.dateKey === todayKey}
|
||||
prepEventId={prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -542,7 +763,7 @@ function UpcomingEvents() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
|
||||
function UpcomingDayCard({ day, isToday, prepEventId, onOpenNote }: { day: DayGroup; isToday: boolean; prepEventId: string | null; onOpenNote: (path: string) => void }) {
|
||||
const dayNum = day.date.getDate()
|
||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||
|
|
@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean })
|
|||
</div>
|
||||
) : (
|
||||
day.events.map((ev, idx) => (
|
||||
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
|
||||
<UpcomingEventItem
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
isLast={idx === count - 1}
|
||||
isPrepTarget={ev.id === prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -588,14 +815,20 @@ function NowBadge() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
|
||||
function UpcomingEventItem({ event, isLast, isPrepTarget, onOpenNote }: { event: UpcomingEvent; isLast: boolean; isPrepTarget: boolean; onOpenNote: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// The next meeting auto-expands its prep; any other meeting with attendees
|
||||
// can be expanded on demand via the Prep toggle (resolves lazily on open).
|
||||
const prepEligible = !event.isAllDay && event.attendees.some((a) => !a.self)
|
||||
const [prepOpen, setPrepOpen] = useState(isPrepTarget)
|
||||
const showPrep = prepEligible && prepOpen
|
||||
const isNow = isEventNow(event)
|
||||
const platform = meetingPlatformLabel(event.conferenceLink)
|
||||
const subtitle = platform ?? event.location
|
||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||
|
||||
return (
|
||||
<div className={cn(!isLast && 'border-b')}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
|
|
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
||||
!isLast && 'border-b',
|
||||
showPrep && 'border-b',
|
||||
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
|
|
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{prepEligible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setPrepOpen((v) => !v) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-expanded={prepOpen}
|
||||
title={prepOpen ? 'Hide prep' : 'Show meeting prep'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
prepOpen ? 'bg-accent text-foreground' : 'bg-background text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
Prep
|
||||
<ChevronDown className={cn('size-3 transition-transform', prepOpen && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
|
|
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</PopoverTrigger>
|
||||
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||
</Popover>
|
||||
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
|
||||
const rows = entries
|
||||
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
|
||||
// Generated prep notes live under Meetings/prep/ — they're upcoming
|
||||
// prep, not past meeting notes, so keep them out of this table.
|
||||
.filter((entry) => !entry.path.startsWith(`${MEETINGS_ROOT}/prep/`))
|
||||
.map((entry) => {
|
||||
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
||||
const parts = relative.split('/')
|
||||
|
|
@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<UpcomingEvents />
|
||||
<UpcomingEvents onOpenNote={onOpenNote} />
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { StepIndicator } from "./step-indicator"
|
|||
import { WelcomeStep } from "./steps/welcome-step"
|
||||
import { LlmSetupStep } from "./steps/llm-setup-step"
|
||||
import { ConnectAccountsStep } from "./steps/connect-accounts-step"
|
||||
import { CodeModeStep } from "./steps/code-mode-step"
|
||||
import { CompletionStep } from "./steps/completion-step"
|
||||
|
||||
interface OnboardingModalProps {
|
||||
|
|
@ -33,6 +34,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
case 2:
|
||||
return <ConnectAccountsStep state={state} />
|
||||
case 3:
|
||||
return <CodeModeStep state={state} />
|
||||
case 4:
|
||||
return <CompletionStep state={state} />
|
||||
}
|
||||
}, [state.currentStep, state])
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import type { Step, OnboardingPath } from "./use-onboarding-state"
|
|||
const ROWBOAT_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
interface StepIndicatorProps {
|
||||
|
|
@ -26,7 +28,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-8 px-4">
|
||||
<div className="flex items-center gap-2 mb-20 px-4">
|
||||
{steps.map((s, i) => (
|
||||
<React.Fragment key={s.step}>
|
||||
{i > 0 && (
|
||||
|
|
@ -37,7 +39,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
|
||||
|
|
@ -54,7 +56,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
|||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium transition-colors duration-300",
|
||||
"absolute top-full mt-1.5 whitespace-nowrap text-[11px] font-medium transition-colors duration-300",
|
||||
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, ArrowLeft, Terminal, CheckCircle2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { startProvisioning, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface CodeModeStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const AGENTS = [
|
||||
{ key: "claude" as const, name: "Claude Code" },
|
||||
{ key: "codex" as const, name: "Codex" },
|
||||
]
|
||||
|
||||
export function CodeModeStep({ state }: CodeModeStepProps) {
|
||||
const { handleNext, handleBack } = state
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [selected, setSelected] = useState<Record<"claude" | "codex", boolean>>({ claude: false, codex: false })
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reflect what's already set up: pre-select installed agents and turn the master
|
||||
// switch on if any agent is already there, so returning users don't start from off.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setStatusLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||
if (cancelled) return
|
||||
setStatus(result)
|
||||
const claudeInstalled = result.claude.installed
|
||||
const codexInstalled = result.codex.installed
|
||||
if (claudeInstalled || codexInstalled) {
|
||||
setEnabled(true)
|
||||
setSelected({ claude: claudeInstalled, codex: codexInstalled })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus(null)
|
||||
} finally {
|
||||
if (!cancelled) setStatusLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const onContinue = useCallback(async () => {
|
||||
if (enabled) {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: true, approvalPolicy: "ask" })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
} catch {
|
||||
// Non-fatal — the user can still enable code mode later from Settings.
|
||||
}
|
||||
setSaving(false)
|
||||
// Kick off engine downloads in the BACKGROUND for selected agents that aren't
|
||||
// installed yet. We deliberately don't block onboarding on the ~200 MB download —
|
||||
// it keeps running in the main process and its progress shows in Settings → Code Mode.
|
||||
for (const a of AGENTS) {
|
||||
if (selected[a.key] && !status?.[a.key].installed) {
|
||||
startProvisioning(a.key, () => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
handleNext()
|
||||
}, [enabled, selected, status, handleNext])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Set Up Code Mode
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center leading-relaxed mb-6 max-w-md mx-auto">
|
||||
Use your existing Claude Code or Codex subscription inside Rowboat to tackle coding
|
||||
tasks and unlock far more workflows, all without leaving the app. Make sure Claude Code
|
||||
and Codex are signed in locally with{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">claude login</code> or{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">codex login</code> in your terminal.
|
||||
</p>
|
||||
|
||||
{statusLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Master enable */}
|
||||
<div className="rounded-xl border px-4 py-3.5 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Enable code mode</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Shows the code mode chip in the composer and lets the assistant delegate to your agents.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} disabled={saving} />
|
||||
</div>
|
||||
|
||||
{/* Per-agent selection (revealed when enabled) */}
|
||||
{enabled && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Agents to set up
|
||||
</span>
|
||||
{AGENTS.map((a) => {
|
||||
const st = status?.[a.key]
|
||||
const ready = (st?.installed ?? false) && (st?.signedIn ?? false)
|
||||
return (
|
||||
<div key={a.key} className="rounded-xl border px-4 py-3 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 text-sm font-medium">{a.name}</div>
|
||||
{ready && <CheckCircle2 className="size-4 text-green-600 shrink-0" />}
|
||||
<Switch
|
||||
checked={selected[a.key]}
|
||||
onCheckedChange={(v) => setSelected((prev) => ({ ...prev, [a.key]: v }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
|
||||
<Button onClick={onContinue} size="lg" className="h-12 text-base font-medium" disabled={saving}>
|
||||
{saving ? <Loader2 className="size-5 animate-spin" /> : "Continue"}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,34 +13,25 @@ export function CompletionStep({ state }: CompletionStepProps) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Animated checkmark */}
|
||||
{/* Title with checkmark on the right */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
{/* Pulsing ring */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0.6 }}
|
||||
animate={{ scale: 1.5, opacity: 0 }}
|
||||
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
|
||||
className="absolute inset-0 rounded-full bg-green-500/20"
|
||||
/>
|
||||
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
className="flex items-center gap-3 mb-3"
|
||||
>
|
||||
You're All Set!
|
||||
</motion.h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
You're All Set!
|
||||
</h2>
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.35 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CheckCircle2 className="size-9 text-green-600 dark:text-green-400" />
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,21 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Logo with ambient glow */}
|
||||
{/* Logo + main heading on the same level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative mb-8"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex items-center gap-4 mb-4"
|
||||
>
|
||||
<div className="absolute inset-0 size-16 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-16" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome to Rowboat
|
||||
</h1>
|
||||
{/* Logo with ambient glow */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 size-12 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-12" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tagline badge */}
|
||||
|
|
@ -28,21 +34,11 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
|
|||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6"
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-10"
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Your AI coworker, with memory
|
||||
</motion.div>
|
||||
|
||||
{/* Main heading */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
Welcome to Rowboat
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface ProviderState {
|
|||
isConnecting: boolean
|
||||
}
|
||||
|
||||
export type Step = 0 | 1 | 2 | 3
|
||||
export type Step = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
|
|
@ -155,8 +155,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
|
|
@ -377,8 +377,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
}, [startGoogleCalendarConnect])
|
||||
|
||||
// New step flow:
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done)
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 0) {
|
||||
if (onboardingPath === 'byok') {
|
||||
|
|
@ -390,6 +390,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setCurrentStep(2)
|
||||
} else if (currentStep === 2) {
|
||||
setCurrentStep(3)
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(4)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
|
|
@ -403,6 +405,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
} else {
|
||||
setCurrentStep(1)
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
|
|
@ -429,13 +433,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
return false
|
||||
}
|
||||
|
||||
const models: string[] = result.models ?? []
|
||||
const catalog: string[] = result.models ?? []
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
const model =
|
||||
(preferred && models.includes(preferred) && preferred) ||
|
||||
models[0] || activeConfig.model.trim() || ""
|
||||
(preferred && catalog.includes(preferred) && preferred) ||
|
||||
catalog[0] || activeConfig.model.trim() || ""
|
||||
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models })
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
// model; users add more from Settings. Persisting the whole catalog here
|
||||
// rendered every model as a separate assistant-model row.
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
setTestState({ status: "success" })
|
||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
||||
|
|
|
|||
878
apps/x/apps/renderer/src/components/product-tour.tsx
Normal file
878
apps/x/apps/renderer/src/components/product-tour.tsx
Normal file
|
|
@ -0,0 +1,878 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TalkingHead, type MascotHat } from '@/components/talking-head'
|
||||
import { AgentsFleet, MascotVignette, type TourVignetteKind } from '@/components/tour-vignettes'
|
||||
import { TourSounds } from '@/lib/tour-sounds'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
import tourClipWelcome from '@/assets/tour/welcome.mp3'
|
||||
import tourClipHome from '@/assets/tour/home.mp3'
|
||||
import tourClipEmail from '@/assets/tour/email.mp3'
|
||||
import tourClipMeetings from '@/assets/tour/meetings.mp3'
|
||||
import tourClipCode from '@/assets/tour/code.mp3'
|
||||
import tourClipKnowledge from '@/assets/tour/knowledge.mp3'
|
||||
import tourClipAgents from '@/assets/tour/agents.mp3'
|
||||
import tourClipWorkspaces from '@/assets/tour/workspaces.mp3'
|
||||
import tourClipChats from '@/assets/tour/chats.mp3'
|
||||
import tourClipComposer from '@/assets/tour/composer.mp3'
|
||||
import tourClipDone from '@/assets/tour/done.mp3'
|
||||
|
||||
export type TourNavTarget =
|
||||
| 'home'
|
||||
| 'email'
|
||||
| 'meetings'
|
||||
| 'code'
|
||||
| 'knowledge'
|
||||
| 'agents'
|
||||
| 'workspaces'
|
||||
|
||||
type TourStep = {
|
||||
id: string
|
||||
/** Matches a [data-tour-id] element. Steps whose target is absent are skipped. */
|
||||
targetId?: string
|
||||
/** View to open when the step starts, via App's navigation handlers. */
|
||||
navigate?: TourNavTarget
|
||||
/** Costume the mascot wears at this stop. */
|
||||
hat?: MascotHat
|
||||
/** Looping animation staged around the mascot while it presents this stop. */
|
||||
vignette?: TourVignetteKind
|
||||
title: string
|
||||
text: string
|
||||
/** Spoken narration, when it should differ from the bubble text. */
|
||||
voiceText?: string
|
||||
}
|
||||
|
||||
const TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'All aboard! ⚓',
|
||||
text: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time. Use Next or your arrow keys.",
|
||||
voiceText: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time.",
|
||||
},
|
||||
{
|
||||
id: 'home',
|
||||
targetId: 'nav-home',
|
||||
navigate: 'home',
|
||||
title: 'First stop: Home',
|
||||
text: 'Home is your landing spot — a quick overview of what needs your attention to get you back into the flow.',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
targetId: 'nav-email',
|
||||
navigate: 'email',
|
||||
hat: 'mailcap',
|
||||
vignette: 'email',
|
||||
title: 'Email',
|
||||
text: 'Read and triage your inbox right here. Rowboat can summarize threads, label messages, and help you draft replies.',
|
||||
},
|
||||
{
|
||||
id: 'meetings',
|
||||
targetId: 'nav-meetings',
|
||||
navigate: 'meetings',
|
||||
hat: 'headphones',
|
||||
vignette: 'meetings',
|
||||
title: 'Meetings',
|
||||
text: 'Record or join meetings, and get transcripts and notes automatically — prep briefs show up before your calls, too.',
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
targetId: 'nav-code',
|
||||
navigate: 'code',
|
||||
hat: 'hardhat',
|
||||
title: 'Code',
|
||||
text: 'The Code section runs coding agents on your projects — point one at a folder and drive it from a chat.',
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
targetId: 'nav-knowledge',
|
||||
navigate: 'knowledge',
|
||||
hat: 'gradcap',
|
||||
vignette: 'brain',
|
||||
title: 'Brain',
|
||||
text: "Brain is your knowledge base — notes, files, and everything Rowboat learns for you, all connected and searchable.",
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
targetId: 'nav-agents',
|
||||
navigate: 'agents',
|
||||
hat: 'captain',
|
||||
vignette: 'agents',
|
||||
title: 'Background agents',
|
||||
text: 'Background agents work on schedules — they keep your Brain fresh and take care of recurring tasks while you row elsewhere.',
|
||||
},
|
||||
{
|
||||
id: 'workspaces',
|
||||
targetId: 'nav-workspaces',
|
||||
navigate: 'workspaces',
|
||||
hat: 'explorer',
|
||||
title: 'Workspaces',
|
||||
text: 'Workspaces hold your project folders and files, so related work stays docked together.',
|
||||
},
|
||||
{
|
||||
id: 'chats',
|
||||
targetId: 'nav-chats',
|
||||
title: 'Chats',
|
||||
text: 'Your recent conversations live here — pick any of them back up right where you left off.',
|
||||
},
|
||||
{
|
||||
id: 'composer',
|
||||
targetId: 'chat-composer',
|
||||
title: 'Talk to Rowboat',
|
||||
text: 'And this is where we talk! Type, dictate with the mic, or turn on voice output — tap my face button and I’ll read replies out loud myself.',
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
hat: 'party',
|
||||
title: "Land ho! 🎉",
|
||||
text: "That's the whole bay — and there's my wake to prove it. Take this voyage again anytime from the bottom of the sidebar. Happy rowing!",
|
||||
},
|
||||
]
|
||||
|
||||
// Pre-recorded narration bundled with the app (no TTS API call, works
|
||||
// offline/signed-out). Regenerate with scripts/generate-tour-audio.mjs after
|
||||
// editing any step's text. Steps without a clip fall back to live TTS.
|
||||
const TOUR_CLIPS: Record<string, string> = {
|
||||
welcome: tourClipWelcome,
|
||||
home: tourClipHome,
|
||||
email: tourClipEmail,
|
||||
meetings: tourClipMeetings,
|
||||
code: tourClipCode,
|
||||
knowledge: tourClipKnowledge,
|
||||
agents: tourClipAgents,
|
||||
workspaces: tourClipWorkspaces,
|
||||
chats: tourClipChats,
|
||||
composer: tourClipComposer,
|
||||
done: tourClipDone,
|
||||
}
|
||||
|
||||
const MASCOT_SIZE = 120
|
||||
const VIEWPORT_MARGIN = 16
|
||||
const BUBBLE_WIDTH = 288
|
||||
const TARGET_RESOLVE_TIMEOUT_MS = 1500
|
||||
const ZOOM_SCALE = 1.05
|
||||
const GLIDE_EASING = 'cubic-bezier(0.45, 0, 0.2, 1)'
|
||||
|
||||
type Pt = { x: number; y: number }
|
||||
type Rect = { left: number; top: number; width: number; height: number }
|
||||
type Spot = Rect & { round: boolean }
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
}
|
||||
|
||||
function quadPoint(p0: Pt, c: Pt, p1: Pt, t: number): Pt {
|
||||
const u = 1 - t
|
||||
return {
|
||||
x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x,
|
||||
y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y,
|
||||
}
|
||||
}
|
||||
|
||||
function quadPathLength(d: string): number {
|
||||
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
p.setAttribute('d', d)
|
||||
return p.getTotalLength()
|
||||
}
|
||||
|
||||
// A data-tour-id can legitimately appear on several elements (e.g. the chat
|
||||
// composer renders in both the full-screen chat and the side pane) — pick the
|
||||
// one that is actually laid out.
|
||||
function findTourTarget(targetId: string): HTMLElement | null {
|
||||
const nodes = document.querySelectorAll<HTMLElement>(`[data-tour-id="${targetId}"]`)
|
||||
for (const el of nodes) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) return el
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mascotDestForCenter(): Pt {
|
||||
return {
|
||||
x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2,
|
||||
y: window.innerHeight / 2 - MASCOT_SIZE / 2,
|
||||
}
|
||||
}
|
||||
|
||||
function mascotDestForRect(rect: Rect): Pt {
|
||||
let x: number
|
||||
let y: number
|
||||
const fitsRight = rect.left + rect.width + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth
|
||||
if (fitsRight) {
|
||||
x = rect.left + rect.width + 20
|
||||
y = rect.top + rect.height / 2 - MASCOT_SIZE / 2
|
||||
} else if (rect.top > MASCOT_SIZE + VIEWPORT_MARGIN * 2) {
|
||||
x = rect.left + rect.width / 2 - MASCOT_SIZE / 2
|
||||
y = rect.top - MASCOT_SIZE - 20
|
||||
} else {
|
||||
x = rect.left - MASCOT_SIZE - 20
|
||||
y = rect.top + rect.height / 2 - MASCOT_SIZE / 2
|
||||
}
|
||||
return {
|
||||
x: clamp(x, VIEWPORT_MARGIN, window.innerWidth - MASCOT_SIZE - VIEWPORT_MARGIN),
|
||||
y: clamp(y, VIEWPORT_MARGIN, window.innerHeight - MASCOT_SIZE - VIEWPORT_MARGIN),
|
||||
}
|
||||
}
|
||||
|
||||
function spotForRect(rect: Rect): Spot {
|
||||
return {
|
||||
left: rect.left - 6,
|
||||
top: rect.top - 6,
|
||||
width: rect.width + 12,
|
||||
height: rect.height + 12,
|
||||
round: false,
|
||||
}
|
||||
}
|
||||
|
||||
function spotForMascot(dest: Pt): Spot {
|
||||
return {
|
||||
left: dest.x - 26,
|
||||
top: dest.y - 18,
|
||||
width: MASCOT_SIZE + 52,
|
||||
height: MASCOT_SIZE + 44,
|
||||
round: true,
|
||||
}
|
||||
}
|
||||
|
||||
type ProductTourProps = {
|
||||
onClose: () => void
|
||||
onNavigate: (target: TourNavTarget) => void
|
||||
ttsAvailable: boolean
|
||||
ttsState: TTSState
|
||||
speak: (text: string) => void
|
||||
speakUrl: (url: string) => void
|
||||
cancelSpeech: () => void
|
||||
getLevel: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* The Grand Voyage: a mascot-guided walkthrough where the app dims to a
|
||||
* night-time bay, the boat rows curved routes between [data-tour-id] anchors
|
||||
* (leaving a dotted wake behind it), a spotlight and gentle camera zoom reveal
|
||||
* each section, and a parchment mini-map charts progress. Narrated with TTS
|
||||
* lip sync when available; ends in confetti.
|
||||
*
|
||||
* Rendered through a portal to <body> so the camera zoom applied to the app
|
||||
* shell never transforms the tour's own fixed-position layers.
|
||||
*/
|
||||
export function ProductTour({
|
||||
onClose,
|
||||
onNavigate,
|
||||
ttsAvailable,
|
||||
ttsState,
|
||||
speak,
|
||||
speakUrl,
|
||||
cancelSpeech,
|
||||
getLevel,
|
||||
}: ProductTourProps) {
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [arrived, setArrived] = useState(false)
|
||||
const [rowing, setRowing] = useState(false)
|
||||
const [flipped, setFlipped] = useState(false)
|
||||
const [bubbleSide, setBubbleSide] = useState<'left' | 'right'>('right')
|
||||
const [spot, setSpot] = useState<Spot | null>(null)
|
||||
const [wakes, setWakes] = useState<{ id: number; d: string }[]>([])
|
||||
const [activeWake, setActiveWake] = useState<{ d: string; len: number } | null>(null)
|
||||
const [confettiOn, setConfettiOn] = useState(false)
|
||||
const [resizeNonce, setResizeNonce] = useState(0)
|
||||
|
||||
const reducedMotion = useMemo(
|
||||
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
)
|
||||
|
||||
// Mascot position is animated by mutating the container's transform directly
|
||||
// (60fps travel without re-rendering React); posRef is the source of truth.
|
||||
const mascotElRef = useRef<HTMLDivElement>(null)
|
||||
const posRef = useRef<Pt>({
|
||||
x: window.innerWidth / 2 - MASCOT_SIZE / 2,
|
||||
y: window.innerHeight + 60,
|
||||
})
|
||||
const travelRafRef = useRef(0)
|
||||
const wakePathElRef = useRef<SVGPathElement>(null)
|
||||
const wakeIdRef = useRef(0)
|
||||
const curveSideRef = useRef(1)
|
||||
const lastSplashRef = useRef(0)
|
||||
|
||||
const directionRef = useRef(1)
|
||||
const enteredStepRef = useRef(-1)
|
||||
const stepIndexRef = useRef(stepIndex)
|
||||
|
||||
// Camera zoom state applied to the app shell (outside the portal)
|
||||
const shellRef = useRef<HTMLElement | null>(null)
|
||||
const zoomRef = useRef<{ ox: number; oy: number; s: number } | null>(null)
|
||||
|
||||
const soundsRef = useRef<TourSounds | null>(null)
|
||||
|
||||
const onCloseRef = useRef(onClose)
|
||||
const onNavigateRef = useRef(onNavigate)
|
||||
const speakRef = useRef(speak)
|
||||
const speakUrlRef = useRef(speakUrl)
|
||||
const cancelSpeechRef = useRef(cancelSpeech)
|
||||
const ttsAvailableRef = useRef(ttsAvailable)
|
||||
|
||||
// Keep latest callbacks/state in refs so the step effect and key handlers
|
||||
// stay stable. Runs before the step effect below (effect order = call order).
|
||||
useEffect(() => {
|
||||
stepIndexRef.current = stepIndex
|
||||
onCloseRef.current = onClose
|
||||
onNavigateRef.current = onNavigate
|
||||
speakRef.current = speak
|
||||
speakUrlRef.current = speakUrl
|
||||
cancelSpeechRef.current = cancelSpeech
|
||||
ttsAvailableRef.current = ttsAvailable
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (mascotElRef.current) {
|
||||
mascotElRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)`
|
||||
}
|
||||
soundsRef.current = new TourSounds()
|
||||
return () => {
|
||||
soundsRef.current?.dispose()
|
||||
soundsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Grab the app shell for the camera zoom; restore it when the tour ends
|
||||
useEffect(() => {
|
||||
const shell = document.querySelector<HTMLElement>('.rowboat-shell')
|
||||
shellRef.current = shell
|
||||
return () => {
|
||||
if (shell) {
|
||||
shell.style.transform = ''
|
||||
shell.style.transformOrigin = ''
|
||||
shell.style.transition = ''
|
||||
}
|
||||
shellRef.current = null
|
||||
zoomRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyZoom = useCallback((origin: { ox: number; oy: number } | null) => {
|
||||
const shell = shellRef.current
|
||||
if (!shell || reducedMotion) return
|
||||
if (origin) {
|
||||
// transform-origin transitions too, so moving between targets pans
|
||||
// smoothly instead of jumping when the origin changes
|
||||
shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}`
|
||||
shell.style.transformOrigin = `${origin.ox}px ${origin.oy}px`
|
||||
shell.style.transform = `scale(${ZOOM_SCALE})`
|
||||
zoomRef.current = { ox: origin.ox, oy: origin.oy, s: ZOOM_SCALE }
|
||||
} else {
|
||||
shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}`
|
||||
shell.style.transform = 'scale(1)'
|
||||
zoomRef.current = null
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
// Where the element will sit on screen once this step's zoom settles:
|
||||
// undo the current zoom mathematically, then apply the upcoming one (whose
|
||||
// origin is the target's own center, so the center never moves).
|
||||
const displayedRect = useCallback((el: HTMLElement, willZoom: boolean): { rect: Rect; origin: { ox: number; oy: number } } => {
|
||||
const m = el.getBoundingClientRect()
|
||||
const z = zoomRef.current
|
||||
let cx = m.left + m.width / 2
|
||||
let cy = m.top + m.height / 2
|
||||
let w = m.width
|
||||
let h = m.height
|
||||
if (z) {
|
||||
cx = z.ox + (cx - z.ox) / z.s
|
||||
cy = z.oy + (cy - z.oy) / z.s
|
||||
w /= z.s
|
||||
h /= z.s
|
||||
}
|
||||
const s = willZoom && !reducedMotion ? ZOOM_SCALE : 1
|
||||
return {
|
||||
rect: { left: cx - (w * s) / 2, top: cy - (h * s) / 2, width: w * s, height: h * s },
|
||||
origin: { ox: cx, oy: cy },
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
const cancelTravel = useCallback(() => {
|
||||
cancelAnimationFrame(travelRafRef.current)
|
||||
}, [])
|
||||
|
||||
const moveMascot = useCallback((p: Pt) => {
|
||||
posRef.current = p
|
||||
if (mascotElRef.current) {
|
||||
mascotElRef.current.style.transform = `translate(${p.x}px, ${p.y}px)`
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Row along a curved path from the current position, drawing the wake as we
|
||||
// go and splashing the oar; commits the wake as a dotted trail on arrival.
|
||||
const startTravel = useCallback((dest: Pt, onArrive: () => void) => {
|
||||
cancelTravel()
|
||||
const from = { ...posRef.current }
|
||||
const dist = Math.hypot(dest.x - from.x, dest.y - from.y)
|
||||
if (dist < 6 || reducedMotion) {
|
||||
moveMascot(dest)
|
||||
setRowing(false)
|
||||
onArrive()
|
||||
return
|
||||
}
|
||||
const dur = clamp(dist * 1.1, 550, 1500)
|
||||
curveSideRef.current = -curveSideRef.current
|
||||
const mx = (from.x + dest.x) / 2
|
||||
const my = (from.y + dest.y) / 2
|
||||
const nx = -(dest.y - from.y) / dist
|
||||
const ny = (dest.x - from.x) / dist
|
||||
const mag = Math.min(140, dist * 0.3) * curveSideRef.current
|
||||
const c = { x: mx + nx * mag, y: my + ny * mag }
|
||||
|
||||
// Wake follows the stern (bottom-center of the mascot box)
|
||||
const sternX = MASCOT_SIZE / 2
|
||||
const sternY = MASCOT_SIZE * 0.82
|
||||
const d = `M ${from.x + sternX} ${from.y + sternY} Q ${c.x + sternX} ${c.y + sternY} ${dest.x + sternX} ${dest.y + sternY}`
|
||||
const len = quadPathLength(d)
|
||||
setActiveWake({ d, len })
|
||||
setFlipped(dest.x < from.x - 4)
|
||||
setRowing(true)
|
||||
lastSplashRef.current = 0
|
||||
|
||||
const t0 = performance.now()
|
||||
const frame = (now: number) => {
|
||||
const raw = Math.min(1, (now - t0) / dur)
|
||||
const t = easeInOutCubic(raw)
|
||||
moveMascot(quadPoint(from, c, dest, t))
|
||||
if (wakePathElRef.current) {
|
||||
wakePathElRef.current.style.strokeDashoffset = String(len * (1 - t))
|
||||
}
|
||||
if (now - lastSplashRef.current > 420) {
|
||||
lastSplashRef.current = now
|
||||
soundsRef.current?.splash()
|
||||
}
|
||||
if (raw < 1) {
|
||||
travelRafRef.current = requestAnimationFrame(frame)
|
||||
} else {
|
||||
setRowing(false)
|
||||
setActiveWake(null)
|
||||
setWakes((ws) => [...ws, { id: wakeIdRef.current++, d }])
|
||||
onArrive()
|
||||
}
|
||||
}
|
||||
travelRafRef.current = requestAnimationFrame(frame)
|
||||
}, [cancelTravel, moveMascot, reducedMotion])
|
||||
|
||||
const playDing = useCallback(() => soundsRef.current?.ding(), [])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
cancelSpeechRef.current()
|
||||
onCloseRef.current()
|
||||
}, [])
|
||||
|
||||
const goTo = useCallback((index: number, direction: 1 | -1) => {
|
||||
directionRef.current = direction
|
||||
if (index < 0) return
|
||||
if (index >= TOUR_STEPS.length) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Silence the current step's narration right away — not on arrival —
|
||||
// so it can't talk over (or into) the next step's speech.
|
||||
cancelSpeechRef.current()
|
||||
setStepIndex(index)
|
||||
}, [finish])
|
||||
|
||||
// Stop any in-flight narration when the tour unmounts
|
||||
useEffect(() => () => cancelSpeechRef.current(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setResizeNonce((n) => n + 1)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
// Enter the current step: navigate, wait for its anchor, aim the spotlight
|
||||
// and camera, row over, then narrate. Re-runs on resize to re-anchor.
|
||||
useEffect(() => {
|
||||
const step = TOUR_STEPS[stepIndex]
|
||||
const entering = enteredStepRef.current !== stepIndex
|
||||
let cancelled = false
|
||||
|
||||
if (entering && step.navigate) {
|
||||
onNavigateRef.current(step.navigate)
|
||||
}
|
||||
|
||||
const settle = (dest: Pt, side: 'left' | 'right', spotlight: Spot, origin: { ox: number; oy: number } | null) => {
|
||||
applyZoom(origin)
|
||||
setSpot(spotlight)
|
||||
setBubbleSide(side)
|
||||
if (!entering) {
|
||||
// Resize while already at this step: jump, keep the bubble up
|
||||
cancelTravel()
|
||||
moveMascot(dest)
|
||||
return
|
||||
}
|
||||
enteredStepRef.current = stepIndex
|
||||
setArrived(false)
|
||||
startTravel(dest, () => {
|
||||
if (cancelled) return
|
||||
setArrived(true)
|
||||
soundsRef.current?.bump()
|
||||
cancelSpeechRef.current()
|
||||
const clip = TOUR_CLIPS[step.id]
|
||||
if (clip) {
|
||||
speakUrlRef.current(clip)
|
||||
} else if (ttsAvailableRef.current) {
|
||||
speakRef.current(step.voiceText ?? step.text)
|
||||
}
|
||||
if (stepIndex === TOUR_STEPS.length - 1) {
|
||||
setConfettiOn(true)
|
||||
soundsRef.current?.fanfare()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!step.targetId) {
|
||||
const dest = mascotDestForCenter()
|
||||
applyZoom(null)
|
||||
settle(dest, 'right', spotForMascot(dest), null)
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelTravel()
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
let pollRaf = 0
|
||||
const attempt = () => {
|
||||
if (cancelled) return
|
||||
const el = findTourTarget(step.targetId!)
|
||||
if (el) {
|
||||
const { rect, origin } = displayedRect(el, true)
|
||||
const dest = mascotDestForRect(rect)
|
||||
const side: 'left' | 'right' = dest.x + MASCOT_SIZE / 2 < window.innerWidth / 2 ? 'right' : 'left'
|
||||
settle(dest, side, spotForRect(rect), origin)
|
||||
return
|
||||
}
|
||||
if (performance.now() - startedAt < TARGET_RESOLVE_TIMEOUT_MS) {
|
||||
pollRaf = requestAnimationFrame(attempt)
|
||||
} else if (entering) {
|
||||
// Anchor never appeared (feature disabled / pane closed) — skip past it
|
||||
goTo(stepIndex + directionRef.current, directionRef.current as 1 | -1)
|
||||
}
|
||||
}
|
||||
attempt()
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(pollRaf)
|
||||
cancelTravel()
|
||||
}
|
||||
}, [stepIndex, resizeNonce, goTo, applyZoom, displayedRect, startTravel, cancelTravel, moveMascot])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
finish()
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
goTo(stepIndexRef.current + 1, 1)
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
goTo(stepIndexRef.current - 1, -1)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [finish, goTo])
|
||||
|
||||
const step = TOUR_STEPS[stepIndex]
|
||||
const isFirst = stepIndex === 0
|
||||
const isLast = stepIndex === TOUR_STEPS.length - 1
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes tour-bubble-in {
|
||||
0% { opacity: 0; transform: translateY(6px) scale(0.97); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes tour-wave-drift {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes tour-stamp-in {
|
||||
0% { opacity: 0; transform: scale(2); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* night falls: dim everything except the spotlight cutout */}
|
||||
{spot && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[64]"
|
||||
style={{
|
||||
left: spot.left,
|
||||
top: spot.top,
|
||||
width: spot.width,
|
||||
height: spot.height,
|
||||
borderRadius: spot.round ? 9999 : 14,
|
||||
boxShadow: '0 0 0 200vmax rgba(7, 14, 26, 0.52)',
|
||||
transition: `all 0.9s ${GLIDE_EASING}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TourWater />
|
||||
|
||||
{/* wake trails: the committed dotted route + the wake being drawn now */}
|
||||
<svg className="pointer-events-none fixed inset-0 z-[66] h-full w-full">
|
||||
{wakes.map((w) => (
|
||||
<path
|
||||
key={w.id}
|
||||
d={w.d}
|
||||
fill="none"
|
||||
stroke="#9CCBEA"
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="1 11"
|
||||
opacity={0.75}
|
||||
/>
|
||||
))}
|
||||
{activeWake && (
|
||||
<path
|
||||
ref={wakePathElRef}
|
||||
d={activeWake.d}
|
||||
fill="none"
|
||||
stroke="#BFE0F5"
|
||||
strokeWidth={5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={activeWake.len}
|
||||
strokeDashoffset={activeWake.len}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<TourMiniMap total={TOUR_STEPS.length} current={stepIndex} arrived={arrived} />
|
||||
|
||||
{/* the boat (position driven imperatively during travel) */}
|
||||
<div
|
||||
ref={mascotElRef}
|
||||
className="fixed left-0 top-0 z-[70]"
|
||||
style={{ width: MASCOT_SIZE, pointerEvents: 'none' }}
|
||||
>
|
||||
{arrived && !reducedMotion && step.vignette && step.vignette !== 'agents' && (
|
||||
<MascotVignette kind={step.vignette} playDing={playDing} />
|
||||
)}
|
||||
<div style={{ transform: flipped ? 'scaleX(-1)' : undefined, transition: 'transform 0.35s ease-in-out' }}>
|
||||
<TalkingHead ttsState={ttsState} getLevel={getLevel} size={MASCOT_SIZE} hat={step.hat} rowing={rowing} />
|
||||
</div>
|
||||
{arrived && (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute top-0 z-10 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg',
|
||||
bubbleSide === 'right' ? 'left-full ml-3' : 'right-full mr-3'
|
||||
)}
|
||||
style={{
|
||||
width: BUBBLE_WIDTH,
|
||||
animation: 'tour-bubble-in 0.25s ease-out',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="End tour"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<p className="pr-6 text-sm font-semibold">{step.title}</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{step.text}</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{stepIndex + 1} / {TOUR_STEPS.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isFirst && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2.5" onClick={() => goTo(stepIndex - 1, -1)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3"
|
||||
onClick={() => (isLast ? finish() : goTo(stepIndex + 1, 1))}
|
||||
>
|
||||
{isLast ? 'Done' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{arrived && !reducedMotion && step.vignette === 'agents' && <AgentsFleet />}
|
||||
|
||||
{confettiOn && !reducedMotion && <ConfettiBurst />}
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/** Animated translucent waves lapping at the bottom of the screen. */
|
||||
function TourWater() {
|
||||
const back = useMemo(() => wavePath(2400, 96, 8, 22), [])
|
||||
const front = useMemo(() => wavePath(2400, 96, 12, 30), [])
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[65] h-24 overflow-hidden" aria-hidden="true">
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 h-full"
|
||||
style={{ width: '200%', animation: 'tour-wave-drift 11s linear infinite' }}
|
||||
viewBox="0 0 2400 96"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={back} fill="#5F9BC9" opacity={0.3} />
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 h-full"
|
||||
style={{ width: '200%', animation: 'tour-wave-drift 7s linear infinite reverse' }}
|
||||
viewBox="0 0 2400 96"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={front} fill="#8FB6D9" opacity={0.35} />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Periodic wave: alternating up/down humps so a -50% translate loops seamlessly
|
||||
// (both hump counts are even, so half the width is a whole number of periods).
|
||||
function wavePath(width: number, height: number, humps: number, amp: number): string {
|
||||
const yTop = 34
|
||||
const seg = width / humps
|
||||
let d = `M 0 ${yTop}`
|
||||
for (let i = 0; i < humps; i++) {
|
||||
d += ` q ${seg / 2} ${i % 2 === 0 ? -amp : amp} ${seg} 0`
|
||||
}
|
||||
d += ` L ${width} ${height} L 0 ${height} Z`
|
||||
return d
|
||||
}
|
||||
|
||||
/** Parchment chart in the corner: islands per stop, dotted route, boat marker. */
|
||||
function TourMiniMap({ total, current, arrived }: { total: number; current: number; arrived: boolean }) {
|
||||
const MW = 184
|
||||
const MH = 96
|
||||
const points = useMemo(
|
||||
() =>
|
||||
Array.from({ length: total }, (_, i) => {
|
||||
const t = total === 1 ? 0 : i / (total - 1)
|
||||
return {
|
||||
x: 14 + (MW - 28) * t,
|
||||
y: MH / 2 + 4 + Math.sin(t * Math.PI * 1.5 + 0.6) * (MH * 0.26),
|
||||
}
|
||||
}),
|
||||
[total]
|
||||
)
|
||||
const route = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ')
|
||||
const boat = points[clamp(current, 0, total - 1)]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-5 left-5 z-[71] rounded-lg border-2 border-[#8A6B3D]/60 bg-[#F4E9CE] px-2 pb-1.5 pt-2 shadow-xl"
|
||||
style={{ transform: 'rotate(-1.2deg)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="mb-0.5 px-1 text-[9px] font-semibold uppercase tracking-[0.22em] text-[#6B5138]">
|
||||
Voyage chart
|
||||
</p>
|
||||
<svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`}>
|
||||
<path d={route} fill="none" stroke="#8A6B3D" strokeWidth={1.5} strokeDasharray="3 4" opacity={0.65} />
|
||||
{points.map((p, i) => {
|
||||
const visited = i < current || (i === current && arrived)
|
||||
return (
|
||||
<g key={i}>
|
||||
<ellipse cx={p.x} cy={p.y} rx={7} ry={5} fill="#DFC896" stroke="#8A6B3D" strokeWidth={1.5} />
|
||||
{visited && (
|
||||
<g style={{ animation: 'tour-stamp-in 0.3s ease-out backwards' }}>
|
||||
<line x1={p.x - 1} y1={p.y - 13} x2={p.x - 1} y2={p.y - 3} stroke="#7A4A21" strokeWidth={1.5} />
|
||||
<path d={`M ${p.x - 1} ${p.y - 13} L ${p.x + 6} ${p.y - 10.5} L ${p.x - 1} ${p.y - 8} Z`} fill="#D9534F" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* boat marker glides between islands in step with the real mascot */}
|
||||
<g style={{ transform: `translate(${boat.x}px, ${boat.y - 7}px)`, transition: `transform 0.9s ${GLIDE_EASING}` }}>
|
||||
<path d="M -7 0 Q 0 4 7 0 Q 4 6 0 6 Q -4 6 -7 0 Z" fill="#54402F" stroke="#3E2E24" strokeWidth={1} />
|
||||
<circle cx={0} cy={-3} r={3} fill="#E8E9F5" stroke="#17171B" strokeWidth={1} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two confetti cannons firing from the bottom corners, canvas-driven. */
|
||||
function ConfettiBurst() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const colors = ['#5B8DEF', '#F2B8BE', '#FFD166', '#7AC74F', '#8FB6D9', '#F2699C']
|
||||
const parts = Array.from({ length: 150 }, (_, i) => {
|
||||
const fromLeft = i % 2 === 0
|
||||
return {
|
||||
x: fromLeft ? 24 : w - 24,
|
||||
y: h - 40,
|
||||
vx: (fromLeft ? 1 : -1) * (2.5 + Math.random() * 6.5),
|
||||
vy: -(9 + Math.random() * 8),
|
||||
size: 5 + Math.random() * 5,
|
||||
color: colors[i % colors.length],
|
||||
rot: Math.random() * Math.PI,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
}
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
const t0 = performance.now()
|
||||
const frame = (now: number) => {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
for (const p of parts) {
|
||||
p.vy += 0.18
|
||||
p.vx *= 0.99
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.rot += p.vr
|
||||
ctx.save()
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate(p.rot)
|
||||
ctx.fillStyle = p.color
|
||||
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.66)
|
||||
ctx.restore()
|
||||
}
|
||||
if (now - t0 < 3200) {
|
||||
raf = requestAnimationFrame(frame)
|
||||
} else {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[72]"
|
||||
style={{ width: '100vw', height: '100vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -25,9 +25,11 @@ import { useTheme } from "@/contexts/theme-context"
|
|||
import { toast } from "sonner"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -50,6 +52,12 @@ const tabs: TabConfig[] = [
|
|||
icon: Plug,
|
||||
description: "Manage accounts and tools",
|
||||
},
|
||||
{
|
||||
id: "mobile",
|
||||
label: "Mobile",
|
||||
icon: Smartphone,
|
||||
description: "Chat with Rowboat from WhatsApp or Telegram",
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
|
|
@ -319,8 +327,8 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
|
|||
]
|
||||
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
|
|
@ -384,38 +392,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
[]
|
||||
)
|
||||
|
||||
const updateModelAt = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number, value: string) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = [...prev[prov].models]
|
||||
models[index] = value
|
||||
return { ...prev, [prov]: { ...prev[prov], models } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const addModel = useCallback(
|
||||
(prov: LlmProviderFlavor) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const removeModel = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = prev[prov].models.filter((_, i) => i !== index)
|
||||
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Load current config from file
|
||||
useEffect(() => {
|
||||
|
|
@ -727,48 +703,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeConfig.models.map((model, index) => (
|
||||
<div key={index} className="group/model relative">
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => updateModelAt(provider, index, e.target.value)}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => updateModelAt(provider, index, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{activeConfig.models.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeModel(provider, index)}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addModel(provider)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add assistant model
|
||||
</button>
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={primaryModel}
|
||||
onValueChange={(value) => updateConfig(provider, { models: [value] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{modelsError && (
|
||||
|
|
@ -1756,66 +1713,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
|
||||
// --- Code Mode Settings ---
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
// Engine provisioning runs in the main process and keeps going even if the Settings
|
||||
// dialog is closed. Track its state at MODULE level (not in the row component, which
|
||||
// unmounts on close) so reopening Settings still shows the live % instead of the Enable
|
||||
// button. A single persistent listener on the progress channel feeds this store.
|
||||
type ProvState = { pct: number | null; error?: string }
|
||||
const provStore: Record<string, ProvState | undefined> = {}
|
||||
// Agents we provisioned this session — used to show "Ready" immediately on success
|
||||
// without waiting for the async status refresh to round-trip (which caused the row to
|
||||
// briefly flash the Enable button again).
|
||||
const enabledOptimistic = new Set<string>()
|
||||
const provListeners = new Set<() => void>()
|
||||
let provChannelHooked = false
|
||||
|
||||
function notifyProv() { provListeners.forEach((l) => l()) }
|
||||
|
||||
function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): void {
|
||||
if (provStore[agent] && !provStore[agent]!.error) return // already in flight
|
||||
provStore[agent] = { pct: null }
|
||||
notifyProv()
|
||||
if (!provChannelHooked) {
|
||||
provChannelHooked = true
|
||||
window.ipc.on('codeMode:engineProgress', (p) => {
|
||||
const cur = provStore[p.agent]
|
||||
if (!cur) return
|
||||
const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct
|
||||
provStore[p.agent] = { pct }
|
||||
notifyProv()
|
||||
})
|
||||
}
|
||||
window.ipc.invoke('codeMode:provisionEngine', { agent })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
// Mark installed optimistically so the row shows "Ready" the instant the flag
|
||||
// clears — don't depend on the async status refresh (which re-renders the parent
|
||||
// separately and left a window showing the Enable button). loadStatus still runs
|
||||
// in the background to sync the real status.
|
||||
enabledOptimistic.add(agent)
|
||||
provStore[agent] = undefined
|
||||
void onDone()
|
||||
} else {
|
||||
provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' }
|
||||
}
|
||||
})
|
||||
.catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } })
|
||||
.finally(notifyProv)
|
||||
}
|
||||
|
||||
function useProvisioning(agent: string): ProvState | undefined {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
const l = () => force((n) => n + 1)
|
||||
provListeners.add(l)
|
||||
return () => { provListeners.delete(l) }
|
||||
}, [])
|
||||
return provStore[agent]
|
||||
}
|
||||
|
||||
function AgentStatusRow({
|
||||
name,
|
||||
agent,
|
||||
|
|
@ -2075,7 +1972,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
|
||||
// --- Notification Settings ---
|
||||
|
||||
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission"
|
||||
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task"
|
||||
|
||||
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
|
||||
{
|
||||
|
|
@ -2093,6 +1990,11 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de
|
|||
label: "Permission requests",
|
||||
description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.",
|
||||
},
|
||||
{
|
||||
key: "background_task",
|
||||
label: "Background agents",
|
||||
description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.",
|
||||
},
|
||||
]
|
||||
|
||||
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
|
|
@ -2321,7 +2223,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
@ -2336,6 +2238,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "mobile" ? (
|
||||
<MobileChannelsSettings dialogOpen={open} />
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? <RowboatModelSettings dialogOpen={open} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { Loader2, MessageCircle, Send, Smartphone } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "sonner"
|
||||
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/src/channels.js"
|
||||
|
||||
type Config = z.infer<typeof ChannelsConfig>
|
||||
type Status = z.infer<typeof ChannelsStatus>
|
||||
|
||||
// Comma/newline separated entries; each entry keeps digits only, so a
|
||||
// formatted number like "+1 (415) 555-1234" survives as one entry instead of
|
||||
// being shattered at the spaces.
|
||||
function parseIdList(draft: string): string[] {
|
||||
return draft
|
||||
.split(/[,;\n]+/)
|
||||
.map((s) => s.replace(/\D/g, ""))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [config, setConfig] = useState<Config | null>(null)
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [tokenDraft, setTokenDraft] = useState("")
|
||||
const [waAllowDraft, setWaAllowDraft] = useState("")
|
||||
const [tgAllowDraft, setTgAllowDraft] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const [cfg, st] = await Promise.all([
|
||||
window.ipc.invoke("channels:getConfig", null),
|
||||
window.ipc.invoke("channels:getStatus", null),
|
||||
])
|
||||
if (cancelled) return
|
||||
setConfig(cfg)
|
||||
setStatus(st)
|
||||
setTokenDraft(cfg.telegram.botToken)
|
||||
setWaAllowDraft(cfg.whatsapp.allowFrom.join(", "))
|
||||
setTgAllowDraft(cfg.telegram.allowFrom.join(", "))
|
||||
} catch {
|
||||
if (!cancelled) toast.error("Failed to load mobile channel settings")
|
||||
}
|
||||
})()
|
||||
const unsubscribe = window.ipc.on("channels:status", (st) => {
|
||||
setStatus(st)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [dialogOpen])
|
||||
|
||||
const save = useCallback(async (next: Config) => {
|
||||
setConfig(next)
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("channels:setConfig", next)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save channel settings")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const wa = status?.whatsapp
|
||||
const tg = status?.telegram
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
|
||||
<Smartphone className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chat with Rowboat from your phone. Send <span className="font-mono">help</span> for
|
||||
commands (<span className="font-mono">list</span>, <span className="font-mono">resume 2</span>,{" "}
|
||||
<span className="font-mono">new</span>, <span className="font-mono">stop</span>) — anything
|
||||
else is a message to the current chat. Your computer must be on with Rowboat running.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageCircle className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">WhatsApp</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{wa?.state === "connected"
|
||||
? `Linked as +${wa.self ?? "?"} — message yourself to use it`
|
||||
: wa?.state === "qr"
|
||||
? "Scan the QR below with your phone"
|
||||
: wa?.state === "starting"
|
||||
? "Connecting…"
|
||||
: wa?.state === "error"
|
||||
? wa.error ?? "Error"
|
||||
: "Links your own WhatsApp as a companion device"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{wa?.state === "connected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke("channels:whatsappLogout", null).catch(() => {
|
||||
toast.error("Failed to unlink WhatsApp")
|
||||
})
|
||||
}}
|
||||
>
|
||||
Unlink
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
checked={config.whatsapp.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(enabled) =>
|
||||
void save({ ...config, whatsapp: { ...config.whatsapp, enabled } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.whatsapp.enabled && wa?.state === "qr" && wa.qrDataUrl && (
|
||||
<div className="flex items-center gap-4 rounded-md border p-3">
|
||||
<img src={wa.qrDataUrl} alt="WhatsApp pairing QR" className="size-40 rounded" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium text-foreground">Link Rowboat to WhatsApp</p>
|
||||
<p>1. Open WhatsApp on your phone</p>
|
||||
<p>2. Settings → Linked Devices → Link a Device</p>
|
||||
<p>3. Scan this code</p>
|
||||
<p className="pt-1">
|
||||
Then message <span className="font-medium">yourself</span> (your own contact) to talk
|
||||
to Rowboat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.whatsapp.enabled && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Additional allowed numbers</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={waAllowDraft}
|
||||
onChange={(e) => setWaAllowDraft(e.target.value)}
|
||||
placeholder="e.g. 14155551234, 919876543210"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void save({
|
||||
...config,
|
||||
whatsapp: { ...config.whatsapp, allowFrom: parseIdList(waAllowDraft) },
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your own number (self-chat) is always allowed. Digits only, with country code.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Telegram */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Send className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Telegram</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tg?.state === "polling"
|
||||
? `Listening${tg.botUsername ? ` as @${tg.botUsername}` : ""}`
|
||||
: tg?.state === "starting"
|
||||
? "Connecting…"
|
||||
: tg?.state === "error"
|
||||
? tg.error ?? "Error"
|
||||
: "Uses your own bot — create one with @BotFather"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.telegram.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(enabled) =>
|
||||
void save({ ...config, telegram: { ...config.telegram, enabled } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.telegram.enabled && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Bot token</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={tokenDraft}
|
||||
onChange={(e) => setTokenDraft(e.target.value)}
|
||||
placeholder="123456789:AAF…"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void save({
|
||||
...config,
|
||||
telegram: { ...config.telegram, botToken: tokenDraft.trim() },
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Message @BotFather on Telegram → /newbot → paste the token here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Allowed chat IDs</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tgAllowDraft}
|
||||
onChange={(e) => setTgAllowDraft(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void save({
|
||||
...config,
|
||||
telegram: { ...config.telegram, allowFrom: parseIdList(tgAllowDraft) },
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Message your bot once — it replies with your chat ID to add here.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ import {
|
|||
import { cn } from "@/lib/utils"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { MascotFaceIcon } from "@/components/talking-head"
|
||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "@/lib/toast"
|
||||
|
|
@ -174,6 +175,8 @@ type SidebarContentPanelProps = {
|
|||
onNewChat?: () => void
|
||||
onToggleBrowser?: () => void
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
/** Starts the mascot-guided product tour. */
|
||||
onStartTour?: () => void
|
||||
/** Which primary destination is currently active, for nav highlighting. */
|
||||
activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'workspaces' | null
|
||||
/** Live meeting recording state, so the recording row can show its indicator/stop. */
|
||||
|
|
@ -422,6 +425,7 @@ export function SidebarContentPanel({
|
|||
onNewChat,
|
||||
onToggleBrowser,
|
||||
onVoiceNoteCreated,
|
||||
onStartTour,
|
||||
activeNav,
|
||||
meetingRecordingState = 'idle',
|
||||
recordingMeetingSource = null,
|
||||
|
|
@ -747,13 +751,14 @@ export function SidebarContentPanel({
|
|||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'home'} onClick={onOpenHome}>
|
||||
<SidebarMenuButton data-tour-id="nav-home" isActive={activeNav === 'home'} onClick={onOpenHome}>
|
||||
<Home className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-email"
|
||||
isActive={activeNav === 'email'}
|
||||
onClick={() => onOpenEmail?.()}
|
||||
className={previewEmail ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -776,6 +781,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-meetings"
|
||||
isActive={activeNav === 'meetings'}
|
||||
onClick={onOpenMeetings}
|
||||
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -854,7 +860,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -862,6 +868,7 @@ export function SidebarContentPanel({
|
|||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-knowledge"
|
||||
isActive={activeNav === 'knowledge'}
|
||||
onClick={() => knowledgeActions.openKnowledgeView()}
|
||||
className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -882,6 +889,7 @@ export function SidebarContentPanel({
|
|||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-agents"
|
||||
isActive={activeNav === 'agents'}
|
||||
onClick={onOpenBgTasks}
|
||||
className={bgAgentsLabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -902,6 +910,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-workspaces"
|
||||
isActive={activeNav === 'workspaces'}
|
||||
onClick={() => knowledgeActions.openWorkspaceAt()}
|
||||
className="h-auto py-1.5"
|
||||
|
|
@ -926,6 +935,7 @@ export function SidebarContentPanel({
|
|||
<SidebarGroupContent>
|
||||
<button
|
||||
type="button"
|
||||
data-tour-id="nav-chats"
|
||||
onClick={() => setChatsExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
|
|
@ -1125,6 +1135,15 @@ export function SidebarContentPanel({
|
|||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
{onStartTour && (
|
||||
<button
|
||||
onClick={onStartTour}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<MascotFaceIcon className="size-4" />
|
||||
<span>Take a tour</span>
|
||||
</button>
|
||||
)}
|
||||
<SettingsDialog>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
|
||||
<Settings className="size-4" />
|
||||
|
|
|
|||
487
apps/x/apps/renderer/src/components/talking-head.tsx
Normal file
487
apps/x/apps/renderer/src/components/talking-head.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const POSITION_STORAGE_KEY = 'talking-head-position'
|
||||
|
||||
// Must match the overlay's `bottom-28 right-8` anchor classes.
|
||||
const ANCHOR_RIGHT_PX = 32
|
||||
const ANCHOR_BOTTOM_PX = 112
|
||||
const VIEWPORT_MARGIN_PX = 8
|
||||
|
||||
// Palette pulled from the mascot artwork: pale lavender body, dark walnut boat.
|
||||
const BODY_FILL = '#E8E9F5'
|
||||
const BODY_STROKE = '#17171B'
|
||||
const CHEEK_FILL = '#F2B8BE'
|
||||
const BOAT_DARK = '#3E2E24'
|
||||
const BOAT_MID = '#54402F'
|
||||
const BOAT_LIGHT = '#6B5138'
|
||||
const MOUTH_FILL = '#2A1E19'
|
||||
|
||||
export type MascotHat =
|
||||
| 'mailcap'
|
||||
| 'headphones'
|
||||
| 'hardhat'
|
||||
| 'gradcap'
|
||||
| 'captain'
|
||||
| 'explorer'
|
||||
| 'party'
|
||||
|
||||
type TalkingHeadProps = {
|
||||
ttsState: TTSState
|
||||
getLevel: () => number
|
||||
size?: number
|
||||
/** Costume piece drawn on the head (used by the product tour). */
|
||||
hat?: MascotHat
|
||||
/** Paddle hard: fast bobbing + oar strokes, e.g. while traveling in the tour. */
|
||||
rowing?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Rowboat mascot as an animated inline SVG: a round pale character sitting
|
||||
* in a wooden rowboat holding an oar. The mouth is driven every animation
|
||||
* frame from the live TTS audio level; eyes blink on a randomized timer.
|
||||
*/
|
||||
export function TalkingHead({ ttsState, getLevel, size = 160, hat, rowing = false }: TalkingHeadProps) {
|
||||
const mouthOpenRef = useRef<SVGEllipseElement>(null)
|
||||
const mouthSmileRef = useRef<SVGPathElement>(null)
|
||||
const oarRef = useRef<SVGGElement>(null)
|
||||
const smoothedRef = useRef(0)
|
||||
const [blinking, setBlinking] = useState(false)
|
||||
|
||||
const speaking = ttsState === 'speaking'
|
||||
const thinking = ttsState === 'synthesizing'
|
||||
|
||||
// Lip sync + oar paddle loop. Writes SVG attributes directly to avoid
|
||||
// re-rendering React at 60fps. Stops itself once speech has ended and the
|
||||
// mouth has settled closed; restarts when `speaking` flips this effect.
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let t = 0
|
||||
const tick = () => {
|
||||
const target = speaking ? getLevel() : 0
|
||||
const prev = smoothedRef.current
|
||||
// Fast attack, slower decay reads as natural mouth movement
|
||||
const smoothed = target > prev ? prev + (target - prev) * 0.5 : prev + (target - prev) * 0.2
|
||||
const settled = !speaking && !rowing && smoothed < 0.005
|
||||
smoothedRef.current = settled ? 0 : smoothed
|
||||
const open = settled ? 0 : Math.min(1, smoothed * 1.6)
|
||||
|
||||
const mouthOpen = mouthOpenRef.current
|
||||
const mouthSmile = mouthSmileRef.current
|
||||
if (mouthOpen && mouthSmile) {
|
||||
if (open > 0.06) {
|
||||
mouthOpen.setAttribute('rx', String(6.5 + open * 4))
|
||||
mouthOpen.setAttribute('ry', String(1.5 + open * 9))
|
||||
mouthOpen.style.opacity = '1'
|
||||
mouthSmile.style.opacity = '0'
|
||||
} else {
|
||||
mouthOpen.style.opacity = '0'
|
||||
mouthSmile.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
const oar = oarRef.current
|
||||
if (oar) {
|
||||
if (rowing) {
|
||||
t += 0.14
|
||||
const angle = Math.sin(t) * 13
|
||||
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
|
||||
} else if (speaking) {
|
||||
t += 0.045
|
||||
const angle = Math.sin(t) * 7
|
||||
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
|
||||
} else {
|
||||
oar.setAttribute('transform', 'rotate(0 128 118)')
|
||||
}
|
||||
}
|
||||
|
||||
if (!settled) {
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [speaking, rowing, getLevel])
|
||||
|
||||
// Randomized blinking
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
let cancelled = false
|
||||
const scheduleBlink = () => {
|
||||
timeout = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setBlinking(true)
|
||||
setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setBlinking(false)
|
||||
scheduleBlink()
|
||||
}, 140)
|
||||
}, 2400 + Math.random() * 2600)
|
||||
}
|
||||
scheduleBlink()
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="talking-head-bob relative select-none"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
animationDuration: rowing ? '0.8s' : speaking ? '1.6s' : '3.2s',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes talking-head-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(-1.6deg); }
|
||||
50% { transform: translateY(-4px) rotate(1.6deg); }
|
||||
}
|
||||
.talking-head-bob {
|
||||
animation-name: talking-head-bob;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
@keyframes talking-head-ripple {
|
||||
0% { transform: scale(0.6); opacity: 0.5; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
.talking-head-ripple {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: talking-head-ripple 2.6s ease-out infinite;
|
||||
}
|
||||
@keyframes talking-head-bubble {
|
||||
0%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
50% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
.talking-head-bubble {
|
||||
animation: talking-head-bubble 1.2s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<svg viewBox="0 0 200 190" width={size} height={size} aria-hidden="true">
|
||||
{/* water ripples under the boat */}
|
||||
<g>
|
||||
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '0s' }} />
|
||||
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '1.3s' }} />
|
||||
<ellipse cx="100" cy="168" rx="52" ry="7" fill="#8FB6D9" opacity="0.18" />
|
||||
</g>
|
||||
|
||||
{/* thinking bubbles while synthesizing */}
|
||||
{thinking && (
|
||||
<g fill={BODY_STROKE} opacity="0.75">
|
||||
<circle className="talking-head-bubble" cx="146" cy="34" r="3" style={{ animationDelay: '0s' }} />
|
||||
<circle className="talking-head-bubble" cx="157" cy="26" r="4.2" style={{ animationDelay: '0.2s' }} />
|
||||
<circle className="talking-head-bubble" cx="170" cy="16" r="5.4" style={{ animationDelay: '0.4s' }} />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* character: head + body blob */}
|
||||
<g>
|
||||
<path
|
||||
d="M 100 22
|
||||
C 129 22 148 43 148 68
|
||||
C 148 82 141 93 131 100
|
||||
C 141 107 147 117 148 128
|
||||
L 52 128
|
||||
C 53 115 60 105 69 99
|
||||
C 59 92 52 81 52 68
|
||||
C 52 43 71 22 100 22 Z"
|
||||
fill={BODY_FILL}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* eyes */}
|
||||
<g style={{ transform: thinking ? 'translateY(-2.5px)' : undefined, transition: 'transform 0.3s' }}>
|
||||
<ellipse
|
||||
cx="84" cy="64" rx="5" ry={blinking ? 0.8 : 7}
|
||||
fill={BODY_STROKE}
|
||||
style={{ transition: 'ry 0.06s' }}
|
||||
/>
|
||||
<ellipse
|
||||
cx="116" cy="64" rx="5" ry={blinking ? 0.8 : 7}
|
||||
fill={BODY_STROKE}
|
||||
style={{ transition: 'ry 0.06s' }}
|
||||
/>
|
||||
<circle cx="86" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
|
||||
<circle cx="118" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
|
||||
</g>
|
||||
{/* cheeks */}
|
||||
<ellipse cx="72" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
|
||||
<ellipse cx="128" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
|
||||
{/* mouth: smile when quiet, open ellipse driven by audio level */}
|
||||
<path
|
||||
ref={mouthSmileRef}
|
||||
d="M 91 80 Q 100 88 109 80"
|
||||
fill="none"
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<ellipse
|
||||
ref={mouthOpenRef}
|
||||
cx="100" cy="84" rx="7" ry="2"
|
||||
fill={MOUTH_FILL}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="3"
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
{hat && <MascotHatArt hat={hat} />}
|
||||
</g>
|
||||
|
||||
{/* oar (rotates while speaking) */}
|
||||
<g ref={oarRef}>
|
||||
<line x1="158" y1="88" x2="88" y2="152" stroke={BODY_STROKE} strokeWidth="12" strokeLinecap="round" />
|
||||
<line x1="158" y1="88" x2="88" y2="152" stroke={BOAT_MID} strokeWidth="7" strokeLinecap="round" />
|
||||
<path
|
||||
d="M 84 148 L 56 170 C 52 173 52 178 57 178 L 90 165 Z"
|
||||
fill={BOAT_DARK}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* hand resting over the oar */}
|
||||
<ellipse cx="121" cy="120" rx="10" ry="8" fill={BODY_FILL} stroke={BODY_STROKE} strokeWidth="4" />
|
||||
|
||||
{/* boat hull (drawn last so it overlaps the body) */}
|
||||
<g>
|
||||
<path
|
||||
d="M 30 120
|
||||
C 50 132 150 132 170 120
|
||||
C 168 142 152 160 100 160
|
||||
C 48 160 32 142 30 120 Z"
|
||||
fill={BOAT_MID}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* plank lines */}
|
||||
<path d="M 36 133 C 60 143 140 143 164 133" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M 44 145 C 66 153 134 153 156 145" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
{/* gunwale highlight */}
|
||||
<path d="M 33 121 C 52 131 148 131 167 121" fill="none" stroke={BOAT_LIGHT} strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TalkingHeadOverlayProps = {
|
||||
ttsState: TTSState
|
||||
getLevel: () => number
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
// Keep the widget fully on-screen relative to its bottom-right CSS anchor.
|
||||
// Falls back to the default render size when the element isn't mounted yet.
|
||||
function clampPositionToViewport(pos: { x: number; y: number }, el: HTMLDivElement | null): { x: number; y: number } {
|
||||
const width = el?.offsetWidth ?? 160
|
||||
const height = el?.offsetHeight ?? 160
|
||||
const baseLeft = window.innerWidth - ANCHOR_RIGHT_PX - width
|
||||
const baseTop = window.innerHeight - ANCHOR_BOTTOM_PX - height
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
|
||||
return {
|
||||
x: clamp(pos.x, VIEWPORT_MARGIN_PX - baseLeft, window.innerWidth - VIEWPORT_MARGIN_PX - width - baseLeft),
|
||||
y: clamp(pos.y, VIEWPORT_MARGIN_PX - baseTop, window.innerHeight - VIEWPORT_MARGIN_PX - height - baseTop),
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredPosition(): { x: number; y: number } {
|
||||
try {
|
||||
const raw = localStorage.getItem(POSITION_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt stored position
|
||||
}
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating, draggable widget that hosts the talking head. Anchored to the
|
||||
* bottom-right of the window (above the composer) and offset by a persisted
|
||||
* drag position, so it hovers over whatever view is active.
|
||||
*/
|
||||
export function TalkingHeadOverlay({ ttsState, getLevel, onDismiss }: TalkingHeadOverlayProps) {
|
||||
// Clamp the stored offset at init so a stale position (e.g. saved on a
|
||||
// bigger window) can't leave the widget stranded off-screen.
|
||||
const [offset, setOffset] = useState(() => clampPositionToViewport(loadStoredPosition(), null))
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragStartRef = useRef<{ pointerX: number; pointerY: number; x: number; y: number } | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const clampToViewport = useCallback(
|
||||
(pos: { x: number; y: number }) => clampPositionToViewport(pos, containerRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
// Re-clamp when the window shrinks
|
||||
useEffect(() => {
|
||||
const handleResize = () => setOffset(prev => clampToViewport(prev))
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [clampToViewport])
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return
|
||||
dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, x: offset.x, y: offset.y }
|
||||
setDragging(true)
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [offset])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const start = dragStartRef.current
|
||||
if (!start) return
|
||||
setOffset({
|
||||
x: start.x + (e.clientX - start.pointerX),
|
||||
y: start.y + (e.clientY - start.pointerY),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragStartRef.current) return
|
||||
dragStartRef.current = null
|
||||
setDragging(false)
|
||||
setOffset(prev => clampToViewport(prev))
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}, [clampToViewport])
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging) return
|
||||
try {
|
||||
localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(offset))
|
||||
} catch {
|
||||
// best-effort persistence
|
||||
}
|
||||
}, [offset, dragging])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'group fixed bottom-28 right-8 z-50 touch-none',
|
||||
dragging ? 'cursor-grabbing' : 'cursor-grab'
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
||||
// Constant value so the entrance animation runs once on mount and
|
||||
// never restarts (re-applying it after a drag would replay the pop).
|
||||
animation: 'talking-head-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
role="img"
|
||||
aria-label="Rowboat talking head"
|
||||
>
|
||||
<style>{`
|
||||
@keyframes talking-head-pop {
|
||||
0% { opacity: 0; scale: 0.4; }
|
||||
100% { opacity: 1; scale: 1; }
|
||||
}
|
||||
`}</style>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={onDismiss}
|
||||
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Hide talking head"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<TalkingHead ttsState={ttsState} getLevel={getLevel} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Costume pieces for the product tour, drawn on the mascot's head. */
|
||||
function MascotHatArt({ hat }: { hat: MascotHat }) {
|
||||
const outline = { stroke: BODY_STROKE, strokeWidth: 4, strokeLinejoin: 'round' as const }
|
||||
switch (hat) {
|
||||
case 'mailcap':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 68 36 Q 100 4 132 36 Q 100 26 68 36 Z" fill="#4A7DDB" {...outline} />
|
||||
<path d="M 126 33 Q 148 31 150 39 Q 132 42 124 39 Z" fill="#3D68B8" {...outline} />
|
||||
</g>
|
||||
)
|
||||
case 'headphones':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke={BODY_STROKE} strokeWidth="11" strokeLinecap="round" />
|
||||
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke="#4B5563" strokeWidth="6" strokeLinecap="round" />
|
||||
<ellipse cx="64" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
|
||||
<ellipse cx="136" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
|
||||
</g>
|
||||
)
|
||||
case 'hardhat':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 66 38 Q 100 0 134 38 Z" fill="#F6C445" {...outline} />
|
||||
<path d="M 96 10 Q 94 22 96 36 L 106 36 Q 107 22 105 9 Z" fill="#E0AC2C" stroke="none" />
|
||||
<path d="M 56 38 L 144 38" fill="none" stroke={BODY_STROKE} strokeWidth="9" strokeLinecap="round" />
|
||||
<path d="M 58 38 L 142 38" fill="none" stroke="#F6C445" strokeWidth="5" strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
case 'gradcap':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 80 28 Q 100 42 120 28 L 120 38 Q 100 50 80 38 Z" fill="#232838" {...outline} />
|
||||
<path d="M 100 2 L 144 20 L 100 38 L 56 20 Z" fill="#2E3450" {...outline} />
|
||||
<path d="M 100 20 Q 124 26 136 34" fill="none" stroke="#E8B94A" strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="137" cy="38" r="4" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
case 'captain':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 68 36 Q 100 -2 132 36 Q 100 28 68 36 Z" fill="#F4F5F9" {...outline} />
|
||||
<path d="M 66 36 Q 100 46 134 36 L 134 42 Q 100 52 66 42 Z" fill="#22262E" {...outline} />
|
||||
<path d="M 122 42 Q 146 42 148 48 Q 130 52 120 48 Z" fill="#22262E" {...outline} />
|
||||
<circle cx="100" cy="38" r="4.5" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
case 'explorer':
|
||||
return (
|
||||
<g>
|
||||
<ellipse cx="100" cy="35" rx="46" ry="9" fill="#C9A46B" {...outline} />
|
||||
<path d="M 72 34 Q 100 4 128 34 Z" fill="#C9A46B" {...outline} />
|
||||
<path d="M 76 30 Q 100 38 124 30" fill="none" stroke="#8A6B3D" strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
case 'party':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 100 -2 L 84 34 L 116 34 Z" fill="#F2699C" {...outline} />
|
||||
<path d="M 92 16 L 111 22 M 88 26 L 114 31" fill="none" stroke="#FFD166" strokeWidth="3.5" strokeLinecap="round" />
|
||||
<circle cx="100" cy="0" r="5" fill="#FFD166" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small static mascot face used as the toolbar toggle icon. */
|
||||
export function MascotFaceIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" className={className} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<ellipse cx="8.6" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
|
||||
<ellipse cx="15.4" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
|
||||
<path d="M 9 14.5 Q 12 17 15 14.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
262
apps/x/apps/renderer/src/components/tour-vignettes.tsx
Normal file
262
apps/x/apps/renderer/src/components/tour-vignettes.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
export type MascotVignetteKind = 'email' | 'meetings' | 'brain'
|
||||
export type TourVignetteKind = MascotVignetteKind | 'agents'
|
||||
|
||||
/**
|
||||
* Little looping "shows" staged around the mascot while it presents a section
|
||||
* during the product tour. Purely decorative: everything is pointer-events-none
|
||||
* and rendered on the tour's own layers, never inside the section's real UI.
|
||||
*/
|
||||
export function MascotVignette({ kind, playDing }: { kind: MascotVignetteKind; playDing?: () => void }) {
|
||||
// One round of dings as the first envelopes land, then let the loop run silent
|
||||
useEffect(() => {
|
||||
if (kind !== 'email' || !playDing) return
|
||||
const timers = [900, 1900, 2900].map((ms) => setTimeout(playDing, ms))
|
||||
return () => timers.forEach(clearTimeout)
|
||||
}, [kind, playDing])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-1/2 top-0 z-0 -translate-x-1/2" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes tour-env-left {
|
||||
0% { opacity: 0; transform: translate(-150px, -130px) rotate(-26deg); }
|
||||
10% { opacity: 1; }
|
||||
52% { opacity: 1; transform: translate(0px, 0px) rotate(5deg); }
|
||||
64% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
|
||||
100% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
|
||||
}
|
||||
@keyframes tour-env-right {
|
||||
0% { opacity: 0; transform: translate(150px, -140px) rotate(26deg); }
|
||||
10% { opacity: 1; }
|
||||
52% { opacity: 1; transform: translate(0px, 0px) rotate(-5deg); }
|
||||
64% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
|
||||
100% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
|
||||
}
|
||||
@keyframes tour-badge-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
@keyframes tour-note-line {
|
||||
0% { width: 0; }
|
||||
18% { width: var(--w); }
|
||||
80% { width: var(--w); opacity: 1; }
|
||||
92%, 100% { width: var(--w); opacity: 0; }
|
||||
}
|
||||
@keyframes tour-quill-bob {
|
||||
0% { transform: translate(4px, 26px) rotate(-8deg); }
|
||||
25% { transform: translate(46px, 30px) rotate(4deg); }
|
||||
50% { transform: translate(6px, 40px) rotate(-8deg); }
|
||||
75% { transform: translate(48px, 44px) rotate(4deg); }
|
||||
100% { transform: translate(4px, 26px) rotate(-8deg); }
|
||||
}
|
||||
@keyframes tour-voice-bar {
|
||||
0%, 100% { transform: scaleY(0.35); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
@keyframes tour-orb {
|
||||
0% { opacity: 0; transform: translate(var(--from-x), var(--from-y)) scale(0.5); }
|
||||
15% { opacity: 0.9; }
|
||||
70% { opacity: 0.9; transform: translate(0, 0) scale(1); }
|
||||
85%, 100% { opacity: 0; transform: translate(0, 6px) scale(0.3); }
|
||||
}
|
||||
@keyframes tour-node-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes tour-edge-draw {
|
||||
from { stroke-dashoffset: 60; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{kind === 'email' && (
|
||||
<div className="relative" style={{ width: 240, height: 150 }}>
|
||||
{[
|
||||
{ anim: 'tour-env-left', delay: 0, x: 78, y: 96 },
|
||||
{ anim: 'tour-env-right', delay: 1.0, x: 128, y: 102 },
|
||||
{ anim: 'tour-env-left', delay: 2.0, x: 104, y: 92 },
|
||||
{ anim: 'tour-env-right', delay: 3.0, x: 90, y: 104 },
|
||||
].map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{ left: e.x, top: e.y, animation: `${e.anim} 4s ease-in-out ${e.delay}s infinite both` }}
|
||||
>
|
||||
<svg width="34" height="24" viewBox="0 0 34 24">
|
||||
<rect x="1.5" y="1.5" width="31" height="21" rx="3" fill="#FFF8E7" stroke="#17171B" strokeWidth="2.5" />
|
||||
<path d="M 2 4 L 17 14 L 32 4" fill="none" stroke="#17171B" strokeWidth="2.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: 148, top: 78, animation: 'tour-badge-pulse 2s ease-in-out infinite' }}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22">
|
||||
<circle cx="11" cy="11" r="9.5" fill="#3FA95C" stroke="#17171B" strokeWidth="2.5" />
|
||||
<path d="M 6.5 11.5 L 9.5 14.5 L 15.5 8" fill="none" stroke="#FFFFFF" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'meetings' && (
|
||||
<div className="relative" style={{ width: 220, height: 160, top: -110 }}>
|
||||
{/* someone's talking */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-end gap-1" style={{ top: 0, height: 26 }}>
|
||||
{[0.9, 1.1, 0.7, 1.3, 0.8].map((dur, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 origin-bottom rounded-full bg-[#8FB6D9]"
|
||||
style={{ height: 22, animation: `tour-voice-bar ${dur}s ease-in-out ${i * 0.12}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* ...and the notepad writes itself */}
|
||||
<div
|
||||
className="absolute left-1/2 rounded-md border-2 border-[#17171B] bg-[#FFFDF6] shadow-md"
|
||||
style={{ top: 36, width: 96, height: 104, transform: 'translateX(-50%) rotate(-3deg)' }}
|
||||
>
|
||||
<div className="mx-2 mt-2 h-1.5 rounded bg-[#D9534F]/70" />
|
||||
{[64, 52, 60, 44].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ml-2 mt-3 h-1.5 rounded bg-[#9AA1AE]"
|
||||
style={{ ['--w' as string]: `${w}px`, animation: `tour-note-line 5s ease-out ${i * 1.1}s infinite both` }}
|
||||
/>
|
||||
))}
|
||||
<svg
|
||||
className="absolute left-0 top-0"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 26 26"
|
||||
style={{ animation: 'tour-quill-bob 5s ease-in-out infinite' }}
|
||||
>
|
||||
<path d="M 4 22 L 10 12 Q 14 4 22 2 Q 18 10 12 14 Z" fill="#6B5138" stroke="#17171B" strokeWidth="2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'brain' && (
|
||||
<div className="relative" style={{ width: 260, height: 180, top: -128 }}>
|
||||
{/* constellation assembling above the head */}
|
||||
<svg className="absolute left-1/2 -translate-x-1/2" width="140" height="80" viewBox="0 0 140 80" style={{ top: 0 }}>
|
||||
{[
|
||||
'M 22 58 L 52 24',
|
||||
'M 52 24 L 88 40',
|
||||
'M 88 40 L 120 18',
|
||||
'M 88 40 L 70 66',
|
||||
].map((d, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#9CCBEA"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="60"
|
||||
style={{ animation: `tour-edge-draw 1.2s ease-out ${0.4 + i * 0.35}s both` }}
|
||||
/>
|
||||
))}
|
||||
{[
|
||||
[22, 58], [52, 24], [88, 40], [120, 18], [70, 66],
|
||||
].map(([cx, cy], i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={5}
|
||||
fill="#BFE0F5"
|
||||
stroke="#17171B"
|
||||
strokeWidth="2"
|
||||
style={{ animation: `tour-node-pulse 2.4s ease-in-out ${i * 0.3}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{/* thought-orbs drifting into the head */}
|
||||
{[
|
||||
{ fx: '-120px', fy: '-30px', delay: 0 },
|
||||
{ fx: '120px', fy: '-40px', delay: 1.1 },
|
||||
{ fx: '-90px', fy: '50px', delay: 2.2 },
|
||||
{ fx: '110px', fy: '46px', delay: 3.3 },
|
||||
].map((o, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: 122,
|
||||
top: 118,
|
||||
width: 16,
|
||||
height: 16,
|
||||
background: 'radial-gradient(circle, #FFF3B8 20%, #FFD166 60%, transparent 75%)',
|
||||
['--from-x' as string]: o.fx,
|
||||
['--from-y' as string]: o.fy,
|
||||
animation: `tour-orb 4.4s ease-in-out ${o.delay}s infinite both`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-agents vignette: a fleet of tiny mascots rowing across the water
|
||||
* at the bottom of the screen while the big one takes a break.
|
||||
*/
|
||||
export function AgentsFleet() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[67] h-28 overflow-hidden" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes tour-fleet-right {
|
||||
from { transform: translateX(-18vw); }
|
||||
to { transform: translateX(112vw); }
|
||||
}
|
||||
@keyframes tour-fleet-left {
|
||||
from { transform: translateX(112vw); }
|
||||
to { transform: translateX(-18vw); }
|
||||
}
|
||||
@keyframes tour-fleet-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||
50% { transform: translateY(-3px) rotate(2deg); }
|
||||
}
|
||||
`}</style>
|
||||
{[
|
||||
{ size: 46, bottom: 4, dur: 16, delay: 0, dir: 'tour-fleet-right' },
|
||||
{ size: 34, bottom: 22, dur: 22, delay: -8, dir: 'tour-fleet-left' },
|
||||
{ size: 28, bottom: 14, dur: 27, delay: -3, dir: 'tour-fleet-right' },
|
||||
].map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute left-0"
|
||||
style={{ bottom: b.bottom, animation: `${b.dir} ${b.dur}s linear ${b.delay}s infinite` }}
|
||||
>
|
||||
<div style={{ animation: 'tour-fleet-bob 1.4s ease-in-out infinite', transform: b.dir === 'tour-fleet-left' ? 'scaleX(-1)' : undefined }}>
|
||||
<MiniRower size={b.size} flipped={b.dir === 'tour-fleet-left'} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniRower({ size, flipped }: { size: number; flipped: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size * 0.75}
|
||||
viewBox="0 0 60 45"
|
||||
style={{ transform: flipped ? 'scaleX(-1)' : undefined }}
|
||||
>
|
||||
<circle cx="27" cy="13" r="10" fill="#E8E9F5" stroke="#17171B" strokeWidth="3" />
|
||||
<circle cx="23.5" cy="11.5" r="1.4" fill="#17171B" />
|
||||
<circle cx="30.5" cy="11.5" r="1.4" fill="#17171B" />
|
||||
<path d="M 23 16 Q 27 19 31 16" fill="none" stroke="#17171B" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<line x1="40" y1="14" x2="20" y2="38" stroke="#17171B" strokeWidth="4.5" strokeLinecap="round" />
|
||||
<line x1="40" y1="14" x2="20" y2="38" stroke="#54402F" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M 7 25 C 17 31 43 31 53 25 C 51 37 43 42 30 42 C 17 42 9 37 7 25 Z" fill="#54402F" stroke="#17171B" strokeWidth="3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
274
apps/x/apps/renderer/src/components/video-call-view.tsx
Normal file
274
apps/x/apps/renderer/src/components/video-call-view.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Mic, MicOff, Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react'
|
||||
|
||||
import { MascotFaceIcon, TalkingHead } from '@/components/talking-head'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type VideoCallStatus = 'listening' | 'thinking' | 'speaking'
|
||||
|
||||
interface VideoCallViewProps {
|
||||
/** Live camera stream from useVideoMode — attached to the user's tile. */
|
||||
streamRef: React.MutableRefObject<MediaStream | null>
|
||||
cameraOn: boolean
|
||||
onToggleCamera: () => void
|
||||
/** User mute = full input pause: no mic audio AND no frame capture. */
|
||||
micMuted: boolean
|
||||
onToggleMic: () => void
|
||||
/** Starting a share collapses this view into the floating popout (the
|
||||
* surface is derived from devices — see App.tsx). */
|
||||
onToggleScreenShare: () => void
|
||||
/** Practice preset: the assistant is coaching this session. */
|
||||
practiceMode?: boolean
|
||||
/** Shrink to the floating pill without touching any devices. */
|
||||
onMinimize: () => void
|
||||
/** Stop the assistant: silence speech and abort the run if still going. */
|
||||
onInterrupt: () => void
|
||||
ttsState: TTSState
|
||||
/** Live TTS output level — drives the mascot's mouth animation. */
|
||||
getTtsLevel: () => number
|
||||
status: VideoCallStatus
|
||||
/** Live transcript of the user's in-progress utterance. */
|
||||
interimText?: string
|
||||
/** The assistant line currently being spoken aloud. */
|
||||
assistantCaption?: string
|
||||
onLeave: () => void
|
||||
}
|
||||
|
||||
const STATUS_DISPLAY: Record<VideoCallStatus, { label: string; dotClass: string }> = {
|
||||
listening: { label: 'Listening', dotClass: 'bg-green-500 animate-pulse' },
|
||||
thinking: { label: 'Thinking…', dotClass: 'bg-amber-400' },
|
||||
speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen call surface: a Meet-style two-tile layout with the user's
|
||||
* webcam on one side and the mascot as the other participant. Shown only
|
||||
* while the camera is on with no screen share (the derived-surface rule in
|
||||
* App.tsx) — sharing or muting the camera moves the call into the floating
|
||||
* popout. The mascot animates with the assistant's speech; dismissing it
|
||||
* swaps in a Meet-style letter avatar ("R"). Live captions run along the
|
||||
* bottom.
|
||||
*/
|
||||
export function VideoCallView({
|
||||
streamRef,
|
||||
cameraOn,
|
||||
onToggleCamera,
|
||||
micMuted,
|
||||
onToggleMic,
|
||||
onToggleScreenShare,
|
||||
practiceMode,
|
||||
onMinimize,
|
||||
onInterrupt,
|
||||
ttsState,
|
||||
getTtsLevel,
|
||||
status,
|
||||
interimText,
|
||||
assistantCaption,
|
||||
onLeave,
|
||||
}: VideoCallViewProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [mascotVisible, setMascotVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameraOn) return
|
||||
const videoEl = videoRef.current
|
||||
if (!videoEl) return
|
||||
videoEl.srcObject = streamRef.current
|
||||
videoEl.play().catch(() => {})
|
||||
return () => {
|
||||
videoEl.srcObject = null
|
||||
}
|
||||
}, [streamRef, cameraOn])
|
||||
|
||||
const userSpeaking = status === 'listening' && Boolean(interimText)
|
||||
const assistantSpeaking = ttsState === 'speaking'
|
||||
|
||||
const caption = assistantSpeaking && assistantCaption
|
||||
? { who: 'Rowboat', text: assistantCaption }
|
||||
: interimText
|
||||
? { who: 'You', text: interimText }
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col bg-neutral-950">
|
||||
{practiceMode && (
|
||||
<span className="absolute left-4 top-4 z-10 flex items-center gap-1.5 rounded-full bg-violet-600/90 px-3 py-1 text-xs font-medium text-white">
|
||||
<Presentation className="h-3.5 w-3.5" />
|
||||
Practice session
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinimize}
|
||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-white/80 transition-colors hover:bg-neutral-700 hover:text-white"
|
||||
aria-label="Minimize call (shares your screen)"
|
||||
title="Minimize — shares your screen so it can help you work"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Participant tiles */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-4 pb-2 md:grid-cols-2">
|
||||
{/* User */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 transition-shadow',
|
||||
userSpeaking && 'ring-2 ring-green-500/80'
|
||||
)}
|
||||
>
|
||||
{cameraOn ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: 'scaleX(-1)' }}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-40 w-40 items-center justify-center rounded-full bg-neutral-700 text-neutral-400" aria-label="Camera off">
|
||||
<User className="h-20 w-20" />
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
|
||||
You
|
||||
</span>
|
||||
{micMuted && (
|
||||
<span className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2 py-0.5 text-sm font-medium text-white">
|
||||
<MicOff className="h-3.5 w-3.5" />
|
||||
Muted — nothing is heard or captured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assistant */}
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 transition-shadow',
|
||||
assistantSpeaking && 'ring-2 ring-sky-400/80'
|
||||
)}
|
||||
>
|
||||
{mascotVisible ? (
|
||||
<TalkingHead ttsState={ttsState} getLevel={getTtsLevel} size={220} />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-40 w-40 items-center justify-center rounded-full bg-sky-600 text-7xl font-medium text-white"
|
||||
aria-label="Rowboat"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
|
||||
Rowboat
|
||||
</span>
|
||||
{status !== 'listening' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2.5 py-1 text-sm font-medium text-white transition-colors hover:bg-red-500"
|
||||
aria-label="Stop the assistant"
|
||||
title={status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMascotVisible((v) => !v)}
|
||||
className="absolute right-3 top-3 rounded-md bg-black/50 px-2 py-1 text-xs text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
{mascotVisible ? 'Hide mascot' : 'Show mascot'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Captions */}
|
||||
<div className="flex h-14 items-center justify-center px-6">
|
||||
{caption && (
|
||||
<div className="max-w-3xl truncate rounded-lg bg-black/60 px-4 py-2 text-sm text-white/90">
|
||||
<span className="mr-2 font-semibold text-white">{caption.who}:</span>
|
||||
{caption.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control bar */}
|
||||
<div className="flex items-center justify-center gap-4 pb-5">
|
||||
<span className="flex items-center gap-2 rounded-full bg-neutral-800 px-3 py-1.5 text-xs font-medium text-white/90">
|
||||
{/* Muted overrides "Listening" — the green pulse would be a lie.
|
||||
Thinking/speaking still show: output continues while muted. */}
|
||||
{micMuted && status === 'listening' ? (
|
||||
<>
|
||||
<span className="block h-2 w-2 rounded-full bg-red-500" />
|
||||
Muted
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn('block h-2 w-2 rounded-full', STATUS_DISPLAY[status].dotClass)} />
|
||||
{STATUS_DISPLAY[status].label}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMic}
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
|
||||
micMuted
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
|
||||
)}
|
||||
aria-label={micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
|
||||
title={micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
|
||||
>
|
||||
{micMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCamera}
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
|
||||
cameraOn
|
||||
? 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
|
||||
: 'bg-red-600 text-white hover:bg-red-500'
|
||||
)}
|
||||
aria-label={cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
title={cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
>
|
||||
{cameraOn ? <Video className="h-5 w-5" /> : <VideoOff className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleScreenShare}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-white/90 transition-colors hover:bg-neutral-700"
|
||||
aria-label="Present your screen"
|
||||
title="Present your screen"
|
||||
>
|
||||
<MonitorUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMascotVisible((v) => !v)}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-white/90 transition-colors hover:bg-neutral-700"
|
||||
aria-label={mascotVisible ? 'Hide mascot' : 'Show mascot'}
|
||||
>
|
||||
<MascotFaceIcon />
|
||||
{!mascotVisible && (
|
||||
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="block h-[1.5px] w-6 -rotate-45 rounded-full bg-white/80" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLeave}
|
||||
className="flex h-10 w-14 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-500"
|
||||
aria-label="End call"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
apps/x/apps/renderer/src/components/video-popout.tsx
Normal file
239
apps/x/apps/renderer/src/components/video-popout.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Maximize2, Mic, MicOff, MonitorUp, PhoneOff, Square, User, Video, VideoOff } from 'lucide-react'
|
||||
|
||||
import { TalkingHead } from '@/components/talking-head'
|
||||
|
||||
type PopoutState = {
|
||||
ttsState: 'idle' | 'synthesizing' | 'speaking'
|
||||
status: 'listening' | 'thinking' | 'speaking' | null
|
||||
cameraOn: boolean
|
||||
/** User mute = full input pause: no mic audio AND no frame capture. */
|
||||
micMuted: boolean
|
||||
screenSharing: boolean
|
||||
interimText: string | null
|
||||
}
|
||||
|
||||
const STATUS_DISPLAY: Record<NonNullable<PopoutState['status']>, { label: string; dotClass: string }> = {
|
||||
listening: { label: 'Listening', dotClass: 'bg-green-500 animate-pulse' },
|
||||
thinking: { label: 'Thinking…', dotClass: 'bg-amber-400' },
|
||||
speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' },
|
||||
}
|
||||
|
||||
const dragRegion = { WebkitAppRegion: 'drag' } as React.CSSProperties
|
||||
const noDragRegion = { WebkitAppRegion: 'no-drag' } as React.CSSProperties
|
||||
|
||||
/**
|
||||
* Content of the always-on-top popout window shown for the whole duration of
|
||||
* a screen share (Meet-style floating mini-call) — it floats over every app,
|
||||
* including Rowboat itself, and is the call's control surface while sharing:
|
||||
* camera toggle, share toggle, end-call. Rendered in its own BrowserWindow
|
||||
* (see `video:setPopout` in the main process); call state streams in over
|
||||
* the `video:popout-state` push channel and control actions round-trip back
|
||||
* through `video:popoutAction`. Captures its own webcam feed — MediaStreams
|
||||
* can't cross windows.
|
||||
*/
|
||||
export function VideoPopout() {
|
||||
// Camera defaults OFF: guessing "on" would flash the user's video for a
|
||||
// beat before the real state arrives — which reads as a bug. The true
|
||||
// state is fetched immediately below.
|
||||
const [state, setState] = useState<PopoutState>({ ttsState: 'idle', status: null, cameraOn: false, micMuted: false, screenSharing: false, interimText: null })
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('video:popout-state', (next) => setState(next))
|
||||
// The main process replays the cached state on did-finish-load, but that
|
||||
// can race this listener's registration — fetch it explicitly too.
|
||||
window.ipc
|
||||
.invoke('video:getPopoutState', null)
|
||||
.then(({ state: cached }) => {
|
||||
if (cached) setState(cached)
|
||||
})
|
||||
.catch(() => {})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Own camera feed, following the main window's camera-on/off state.
|
||||
useEffect(() => {
|
||||
if (!state.cameraOn) return
|
||||
let stream: MediaStream | null = null
|
||||
let cancelled = false
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: { width: { ideal: 640 }, facingMode: 'user' }, audio: false })
|
||||
.then((s) => {
|
||||
if (cancelled) {
|
||||
s.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
stream = s
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = s
|
||||
videoRef.current.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[popout] camera failed:', err))
|
||||
return () => {
|
||||
cancelled = true
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
if (videoRef.current) videoRef.current.srcObject = null
|
||||
}
|
||||
}, [state.cameraOn])
|
||||
|
||||
// The popout has no TTS audio pipeline — synthesize a plausible mouth level
|
||||
// so the mascot still animates while the assistant speaks in the main window.
|
||||
const getLevel = useCallback(() => 0.45 + 0.35 * Math.sin(performance.now() / 90), [])
|
||||
|
||||
const sendAction = useCallback((action: 'toggle-mic' | 'toggle-camera' | 'toggle-share' | 'stop-speaking' | 'end-call' | 'expand') => {
|
||||
void window.ipc.invoke('video:popoutAction', { action }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const statusDisplay = state.status ? STATUS_DISPLAY[state.status] : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-screen w-screen select-none flex-col gap-1.5 bg-neutral-900 p-1.5"
|
||||
style={dragRegion}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 gap-1.5">
|
||||
<div className="relative flex-1 overflow-hidden rounded-lg bg-neutral-800">
|
||||
{state.cameraOn ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: 'scaleX(-1)' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-700 text-neutral-400">
|
||||
<User className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute bottom-1 left-1.5 rounded bg-black/50 px-1 py-px text-[10px] text-white">
|
||||
You
|
||||
</span>
|
||||
{/* Persistent consent badge — the user must always be able to see
|
||||
at a glance that their screen is going out. Muted pauses frame
|
||||
capture while keeping the share stream open, so say so. */}
|
||||
{state.screenSharing && (
|
||||
<span className="absolute left-1.5 top-1.5 flex items-center gap-1 rounded-full bg-sky-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<span className={`block h-1.5 w-1.5 rounded-full bg-white ${state.micMuted ? '' : 'animate-pulse'}`} />
|
||||
{state.micMuted ? 'Sharing paused' : 'Sharing screen'}
|
||||
</span>
|
||||
)}
|
||||
{state.micMuted && (
|
||||
<span className="absolute bottom-1 right-1.5 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<MicOff className="h-2.5 w-2.5" />
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex flex-1 items-center justify-center overflow-hidden rounded-lg bg-neutral-800">
|
||||
<TalkingHead ttsState={state.ttsState} getLevel={getLevel} size={84} />
|
||||
<span className="absolute bottom-1 left-1.5 rounded bg-black/50 px-1 py-px text-[10px] text-white">
|
||||
Rowboat
|
||||
</span>
|
||||
{statusDisplay && (
|
||||
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded-full bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{/* Muted overrides "Listening" — the green pulse would be a lie. */}
|
||||
{state.micMuted && state.status === 'listening' ? (
|
||||
<>
|
||||
<span className="block h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
Muted
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={`block h-1.5 w-1.5 rounded-full ${statusDisplay.dotClass}`} />
|
||||
{statusDisplay.label}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{(state.status === 'speaking' || state.status === 'thinking') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('stop-speaking')}
|
||||
className="absolute bottom-1 right-1.5 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white hover:bg-red-500"
|
||||
style={noDragRegion}
|
||||
aria-label="Stop the assistant"
|
||||
title={state.status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
|
||||
>
|
||||
<Square className="h-2.5 w-2.5 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Live caption of the in-progress utterance, floating over the tiles */}
|
||||
{state.interimText && (
|
||||
<div className="pointer-events-none absolute inset-x-1.5 bottom-9 flex justify-center">
|
||||
<span className="max-w-full truncate rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-white/90">
|
||||
{state.interimText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control bar — actions execute in the main app window */}
|
||||
<div className="flex h-7 shrink-0 items-center justify-center gap-2" style={noDragRegion}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('toggle-mic')}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
state.micMuted
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
|
||||
}`}
|
||||
aria-label={state.micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
|
||||
title={state.micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
|
||||
>
|
||||
{state.micMuted ? <MicOff className="h-3.5 w-3.5" /> : <Mic className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('toggle-camera')}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
state.cameraOn
|
||||
? 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
|
||||
: 'bg-red-600 text-white hover:bg-red-500'
|
||||
}`}
|
||||
aria-label={state.cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
title={state.cameraOn ? 'Turn off camera' : 'Turn on camera'}
|
||||
>
|
||||
{state.cameraOn ? <Video className="h-3.5 w-3.5" /> : <VideoOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('toggle-share')}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
state.screenSharing
|
||||
? 'bg-sky-600 text-white hover:bg-sky-500'
|
||||
: 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
|
||||
}`}
|
||||
aria-label={state.screenSharing ? 'Stop sharing screen' : 'Share your screen'}
|
||||
title={state.screenSharing ? 'Stop sharing screen' : 'Share your screen'}
|
||||
>
|
||||
<MonitorUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('end-call')}
|
||||
className="flex h-6 w-8 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-500"
|
||||
aria-label="End call"
|
||||
title="End call"
|
||||
>
|
||||
<PhoneOff className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAction('expand')}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-700 text-white/90 transition-colors hover:bg-neutral-600"
|
||||
aria-label="Expand to full screen (stops screen sharing)"
|
||||
title="Expand to full screen (stops sharing)"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
|
|
@ -27,6 +29,7 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -55,10 +58,26 @@ type WorkspaceActions = {
|
|||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
createNote: (parentPath?: string) => void
|
||||
addGoogleDoc: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function GoogleDriveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
|
||||
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
|
||||
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
tree: TreeNode[]
|
||||
initialPath?: string | null
|
||||
|
|
@ -68,6 +87,24 @@ type WorkspaceViewProps = {
|
|||
onNavigate: (path: string) => void
|
||||
onOpenNote: (path: string) => void
|
||||
onCreateWorkspace: (name: string) => Promise<void>
|
||||
// Opens a previous chat (run) whose work directory is set to this workspace.
|
||||
onOpenRun: (runId: string) => void
|
||||
}
|
||||
|
||||
type WorkspaceChat = {
|
||||
id: string
|
||||
title?: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
function formatChatAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
|
|
@ -126,9 +163,12 @@ function readFileAsBase64(file: File): Promise<string> {
|
|||
})
|
||||
}
|
||||
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onOpenRun }: WorkspaceViewProps) {
|
||||
const currentPath = initialPath || WORKSPACE_ROOT
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [chatsOpen, setChatsOpen] = useState(false)
|
||||
const [chats, setChats] = useState<WorkspaceChat[]>([])
|
||||
const [chatsLoading, setChatsLoading] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -165,6 +205,32 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
})
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
// Load the chats whose work directory is this workspace folder (or nested
|
||||
// inside it). The work directory is stored as an absolute path per run, so
|
||||
// resolve this folder against the workspace root before querying.
|
||||
const loadChats = useCallback(async () => {
|
||||
if (isRoot) {
|
||||
setChats([])
|
||||
return
|
||||
}
|
||||
setChatsLoading(true)
|
||||
try {
|
||||
const { root } = await window.ipc.invoke('workspace:getRoot', null)
|
||||
const abs = `${root.replace(/\/$/, '')}/${currentPath}`
|
||||
const { runs } = await window.ipc.invoke('runs:listByWorkDir', { dir: abs })
|
||||
setChats(runs.map((r) => ({ id: r.id, title: r.title, createdAt: r.createdAt, modifiedAt: r.modifiedAt })))
|
||||
} catch (err) {
|
||||
console.error('Failed to load workspace chats:', err)
|
||||
setChats([])
|
||||
} finally {
|
||||
setChatsLoading(false)
|
||||
}
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
useEffect(() => {
|
||||
void loadChats()
|
||||
}, [loadChats])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: TreeNode) => {
|
||||
if (renameTarget) return
|
||||
|
|
@ -335,30 +401,48 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isRoot && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={chatsOpen ? 'secondary' : 'outline'}
|
||||
onClick={() => setChatsOpen((v) => !v)}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Chats{chats.length ? ` (${chats.length})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => actions.revealInFileManager(currentPath, true)}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
Open in {fileManagerName}
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add workspace
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="w-full">
|
||||
<Button size="sm">
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => actions.createNote(currentPath)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New note
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => actions.addGoogleDoc(currentPath)}>
|
||||
<GoogleDriveIcon className="mr-2 size-4" />
|
||||
Add Google Doc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
Add files…
|
||||
|
|
@ -396,6 +480,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto px-6 py-6"
|
||||
onDragEnter={handleDragEnter}
|
||||
|
|
@ -523,6 +608,45 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!isRoot && chatsOpen && (
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">Chats</span>
|
||||
{chatsLoading && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!chatsLoading && chats.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
No chats use this workspace yet. Set a chat's work directory to this folder and it will appear here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{chats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
type="button"
|
||||
onClick={() => onOpenRun(chat.id)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-[13px] text-foreground">
|
||||
{chat.title || '(Untitled chat)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatChatAt(chat.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={addOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||
|
|
@ -196,6 +197,25 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
}, 1000);
|
||||
}, [writeTranscriptToFile]);
|
||||
|
||||
const stopInputCapture = useCallback(() => {
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
if (micStreamRef.current) {
|
||||
micStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
micStreamRef.current = null;
|
||||
}
|
||||
if (systemStreamRef.current) {
|
||||
systemStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
systemStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writeTimerRef.current) {
|
||||
clearTimeout(writeTimerRef.current);
|
||||
|
|
@ -213,28 +233,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = null;
|
||||
}
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
if (micStreamRef.current) {
|
||||
micStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
micStreamRef.current = null;
|
||||
}
|
||||
if (systemStreamRef.current) {
|
||||
systemStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
systemStreamRef.current = null;
|
||||
}
|
||||
stopInputCapture();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [stopInputCapture]);
|
||||
|
||||
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||
if (state !== 'idle') return null;
|
||||
|
|
@ -551,12 +556,14 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
if (state !== 'recording') return;
|
||||
setState('stopping');
|
||||
|
||||
stopInputCapture();
|
||||
await finalizeDeepgramStream(wsRef.current, 2200);
|
||||
cleanup();
|
||||
interimRef.current = new Map();
|
||||
await writeTranscriptToFile();
|
||||
interimRef.current = new Map();
|
||||
|
||||
setState('idle');
|
||||
}, [state, cleanup, writeTranscriptToFile]);
|
||||
}, [state, cleanup, stopInputCapture, writeTranscriptToFile]);
|
||||
|
||||
return { state, start, stop };
|
||||
}
|
||||
|
|
|
|||
123
apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx
Normal file
123
apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
import type { SessionsClient } from '@/lib/session-chat/client'
|
||||
import type { SessionFeedListener } from '@/lib/session-chat/feed'
|
||||
import {
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
requested,
|
||||
sessionState,
|
||||
turnCompleted,
|
||||
user,
|
||||
} from '@/lib/session-chat/test-fixtures'
|
||||
import { isChatMessage } from '@/lib/chat-conversation'
|
||||
import { useSessionChat } from './useSessionChat'
|
||||
|
||||
const S1 = 'sess-1'
|
||||
|
||||
function makeDeps() {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = []
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
let unsubscribed = 0
|
||||
const sessions = new Map([[S1, sessionState(S1, ['turn-1'])]])
|
||||
const turns = new Map([['turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1')]])
|
||||
const client: SessionsClient = {
|
||||
create: async () => ({ sessionId: 'x' }),
|
||||
list: async () => ({ sessions: [] }),
|
||||
get: async (sessionId) => {
|
||||
const state = sessions.get(sessionId)
|
||||
if (!state) throw new Error('session not found')
|
||||
return state
|
||||
},
|
||||
getTurn: async (turnId) => ({ turnId, events: turns.get(turnId) ?? [] }),
|
||||
sendMessage: async (...args) => {
|
||||
calls.push({ method: 'sendMessage', args })
|
||||
return { turnId: 'turn-2' }
|
||||
},
|
||||
respondToPermission: async (...args) => {
|
||||
calls.push({ method: 'respondToPermission', args })
|
||||
},
|
||||
respondToAskHuman: async (...args) => {
|
||||
calls.push({ method: 'respondToAskHuman', args })
|
||||
},
|
||||
stopTurn: async (...args) => {
|
||||
calls.push({ method: 'stopTurn', args })
|
||||
},
|
||||
resumeTurn: async () => undefined,
|
||||
setTitle: async () => undefined,
|
||||
delete: async () => undefined,
|
||||
}
|
||||
return {
|
||||
deps: {
|
||||
client,
|
||||
subscribeFeed: (listener: SessionFeedListener) => {
|
||||
emit = listener
|
||||
return () => {
|
||||
unsubscribed += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
calls,
|
||||
emit: (event: SessionBusEvent) => emit(event),
|
||||
getUnsubscribed: () => unsubscribed,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSessionChat', () => {
|
||||
it('seeds from the session, follows live events, and routes actions', async () => {
|
||||
const { deps, calls, emit } = makeDeps()
|
||||
const { result } = renderHook(() => useSessionChat(S1, deps), { wrapper: StrictMode })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.latestTurnId).toBe('turn-1')
|
||||
})
|
||||
expect(
|
||||
result.current.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['q1', 'a1'])
|
||||
|
||||
// A new turn streams in over the feed.
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
|
||||
emit({
|
||||
kind: 'turn-event',
|
||||
sessionId: S1,
|
||||
turnId: 'turn-2',
|
||||
event: { type: 'text_delta', turnId: 'turn-2', modelCallIndex: 0, delta: 'a2…' },
|
||||
})
|
||||
})
|
||||
expect(result.current.latestTurnId).toBe('turn-2')
|
||||
expect(result.current.chatState?.currentAssistantMessage).toBe('a2…')
|
||||
expect(result.current.chatState?.isProcessing).toBe(true)
|
||||
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: completed('turn-2', 0, assistantText('a2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: turnCompleted('turn-2', 'a2') })
|
||||
})
|
||||
expect(result.current.chatState?.isProcessing).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.respondToPermission('tc1', 'deny')
|
||||
await result.current.stop()
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{ method: 'respondToPermission', args: ['turn-2', 'tc1', 'deny', undefined] },
|
||||
{ method: 'stopTurn', args: ['turn-2'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('unsubscribes from the feed on unmount (StrictMode double-mounts included)', async () => {
|
||||
const { deps, getUnsubscribed } = makeDeps()
|
||||
const { unmount } = renderHook(() => useSessionChat(S1, deps), {
|
||||
wrapper: StrictMode,
|
||||
})
|
||||
unmount()
|
||||
// StrictMode's simulated cleanup plus the real unmount: every subscribe
|
||||
// was matched by an unsubscribe.
|
||||
expect(getUnsubscribed()).toBe(2)
|
||||
})
|
||||
})
|
||||
36
apps/x/apps/renderer/src/hooks/useSessionChat.ts
Normal file
36
apps/x/apps/renderer/src/hooks/useSessionChat.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { ipcSessionsClient } from '@/lib/session-chat/client'
|
||||
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
|
||||
import { SessionChatStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
|
||||
|
||||
const defaultDeps: SessionChatStoreDeps = {
|
||||
client: ipcSessionsClient,
|
||||
subscribeFeed: subscribeSessionFeed,
|
||||
}
|
||||
|
||||
// Thin subscription over SessionChatStore — all logic (seeding, feed events,
|
||||
// reducer, overlay, action routing) lives in the store, which is unit-tested
|
||||
// without React. `deps` is injectable for tests.
|
||||
export function useSessionChat(
|
||||
sessionId: string | null,
|
||||
deps: SessionChatStoreDeps = defaultDeps,
|
||||
) {
|
||||
const [store] = useState(() => new SessionChatStore(deps))
|
||||
useEffect(() => store.connect(), [store])
|
||||
useEffect(() => {
|
||||
void store.setSession(sessionId)
|
||||
}, [store, sessionId])
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
||||
return useMemo(
|
||||
() => ({
|
||||
...snapshot,
|
||||
sendMessage: store.sendMessage,
|
||||
respondToPermission: store.respondToPermission,
|
||||
answerAskHuman: store.answerAskHuman,
|
||||
stop: store.stop,
|
||||
}),
|
||||
[snapshot, store],
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionChat = ReturnType<typeof useSessionChat>
|
||||
33
apps/x/apps/renderer/src/hooks/useSessions.ts
Normal file
33
apps/x/apps/renderer/src/hooks/useSessions.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { ipcSessionsClient } from '@/lib/session-chat/client'
|
||||
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
|
||||
import { SessionListStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
|
||||
|
||||
const defaultDeps: SessionChatStoreDeps = {
|
||||
client: ipcSessionsClient,
|
||||
subscribeFeed: subscribeSessionFeed,
|
||||
}
|
||||
|
||||
// The session list (chat history sidebar): seeded from sessions:list, kept
|
||||
// current by index-changed feed events. Logic lives in SessionListStore.
|
||||
export function useSessions(deps: SessionChatStoreDeps = defaultDeps) {
|
||||
const [store] = useState(() => new SessionListStore(deps))
|
||||
useEffect(() => {
|
||||
const disconnect = store.connect()
|
||||
void store.load()
|
||||
return disconnect
|
||||
}, [store])
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
||||
const client = deps.client
|
||||
return useMemo(
|
||||
() => ({
|
||||
...snapshot,
|
||||
createSession: (input: { title?: string } = {}) => client.create(input),
|
||||
deleteSession: (sessionId: string) => client.delete(sessionId),
|
||||
setTitle: (sessionId: string, title: string) => client.setTitle(sessionId, title),
|
||||
}),
|
||||
[snapshot, client],
|
||||
)
|
||||
}
|
||||
|
||||
export type Sessions = ReturnType<typeof useSessions>
|
||||
326
apps/x/apps/renderer/src/hooks/useVideoMode.ts
Normal file
326
apps/x/apps/renderer/src/hooks/useVideoMode.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type VideoModeState = 'idle' | 'starting' | 'live';
|
||||
export type ScreenShareState = 'idle' | 'starting' | 'live';
|
||||
|
||||
export interface CapturedVideoFrame {
|
||||
/** base64-encoded JPEG bytes (no data: prefix) — shape of the UserImagePart wire format */
|
||||
data: string;
|
||||
mediaType: string;
|
||||
capturedAt: string; // ISO timestamp
|
||||
/** data: URL of the same frame, for direct display in the transcript */
|
||||
dataUrl: string;
|
||||
source: 'camera' | 'screen';
|
||||
}
|
||||
|
||||
// Frames are grabbed once per second — dense enough to catch expression and
|
||||
// posture changes while the user talks. Per message we attach at most
|
||||
// MAX_*_FRAMES_PER_MESSAGE frames, evenly sampled across the window since the
|
||||
// last send, so long monologues don't balloon the request.
|
||||
const CAPTURE_INTERVAL_MS = 1000;
|
||||
const MAX_CAMERA_FRAMES_PER_MESSAGE = 12;
|
||||
// Screen frames are ~4x the resolution (and tokens) of camera frames, and the
|
||||
// latest view matters far more than the trajectory — keep the cap small.
|
||||
const MAX_SCREEN_FRAMES_PER_MESSAGE = 4;
|
||||
// Rolling buffer bound (~2 minutes). The buffer only needs to cover the gap
|
||||
// between two sends; anything older is stale context anyway.
|
||||
const MAX_BUFFERED_FRAMES = 120;
|
||||
// Downscale targets. 512px wide JPEG keeps a webcam frame around 20-40KB —
|
||||
// cheap enough to inline a dozen per message as multimodal image parts.
|
||||
// Screen captures keep 1280px so on-screen text stays legible to the model.
|
||||
const CAMERA_FRAME_WIDTH = 512;
|
||||
const SCREEN_FRAME_WIDTH = 1280;
|
||||
const CAMERA_JPEG_QUALITY = 0.65;
|
||||
const SCREEN_JPEG_QUALITY = 0.7;
|
||||
|
||||
interface BufferedFrame {
|
||||
dataUrl: string;
|
||||
capturedAt: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
// One capture pipeline: stream → offscreen <video> → canvas JPEG → ring buffer.
|
||||
interface CapturePipe {
|
||||
stream: MediaStream | null;
|
||||
videoEl: HTMLVideoElement | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interval: ReturnType<typeof setInterval> | null;
|
||||
frames: BufferedFrame[];
|
||||
lastCollectTs: number;
|
||||
}
|
||||
|
||||
const emptyPipe = (): CapturePipe => ({
|
||||
stream: null,
|
||||
videoEl: null,
|
||||
canvas: null,
|
||||
interval: null,
|
||||
frames: [],
|
||||
lastCollectTs: 0,
|
||||
});
|
||||
|
||||
function capturePipeFrame(pipe: CapturePipe, width: number, quality: number) {
|
||||
const videoEl = pipe.videoEl;
|
||||
if (!videoEl || videoEl.readyState < 2 || videoEl.videoWidth === 0) return;
|
||||
if (!pipe.canvas) {
|
||||
pipe.canvas = document.createElement('canvas');
|
||||
}
|
||||
const canvas = pipe.canvas;
|
||||
const scale = Math.min(1, width / videoEl.videoWidth);
|
||||
canvas.width = Math.round(videoEl.videoWidth * scale);
|
||||
canvas.height = Math.round(videoEl.videoHeight * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||
// A near-empty data URL means the frame was blank (source still warming up)
|
||||
if (dataUrl.length < 100) return;
|
||||
pipe.frames.push({ dataUrl, capturedAt: new Date().toISOString(), ts: Date.now() });
|
||||
if (pipe.frames.length > MAX_BUFFERED_FRAMES) {
|
||||
pipe.frames.splice(0, pipe.frames.length - MAX_BUFFERED_FRAMES);
|
||||
}
|
||||
}
|
||||
|
||||
function attachPipeSource(pipe: CapturePipe, stream: MediaStream, grab: () => void) {
|
||||
pipe.stream = stream;
|
||||
// Offscreen <video> that feeds the capture canvas; any visible preview
|
||||
// attaches to the same MediaStream separately.
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.muted = true;
|
||||
videoEl.playsInline = true;
|
||||
videoEl.srcObject = stream;
|
||||
pipe.videoEl = videoEl;
|
||||
videoEl.play().catch(() => {});
|
||||
// First frame as soon as the source delivers data, then steady-state cadence.
|
||||
videoEl.addEventListener('loadeddata', () => grab(), { once: true });
|
||||
pipe.interval = setInterval(grab, CAPTURE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function teardownPipe(pipe: CapturePipe) {
|
||||
if (pipe.interval) {
|
||||
clearInterval(pipe.interval);
|
||||
pipe.interval = null;
|
||||
}
|
||||
if (pipe.videoEl) {
|
||||
pipe.videoEl.srcObject = null;
|
||||
pipe.videoEl = null;
|
||||
}
|
||||
if (pipe.stream) {
|
||||
pipe.stream.getTracks().forEach((t) => t.stop());
|
||||
pipe.stream = null;
|
||||
}
|
||||
pipe.frames = [];
|
||||
pipe.lastCollectTs = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain frames captured since the previous collection, evenly sampled down to
|
||||
* `max` (always keeping the newest). Falls back to the single most recent
|
||||
* frame when nothing new accumulated (rapid-fire messages), so every message
|
||||
* carries at least one frame once the source has warmed up.
|
||||
*/
|
||||
function drainPipe(pipe: CapturePipe, max: number, source: CapturedVideoFrame['source']): CapturedVideoFrame[] {
|
||||
const all = pipe.frames;
|
||||
if (all.length === 0) return [];
|
||||
|
||||
let window_ = all.filter((f) => f.ts > pipe.lastCollectTs);
|
||||
if (window_.length === 0) {
|
||||
window_ = [all[all.length - 1]];
|
||||
}
|
||||
pipe.lastCollectTs = window_[window_.length - 1].ts;
|
||||
|
||||
let sampled: BufferedFrame[];
|
||||
if (window_.length <= max) {
|
||||
sampled = window_;
|
||||
} else {
|
||||
sampled = [];
|
||||
const step = (window_.length - 1) / (max - 1);
|
||||
for (let i = 0; i < max; i++) {
|
||||
sampled.push(window_[Math.round(i * step)]);
|
||||
}
|
||||
}
|
||||
|
||||
return sampled.map((f) => ({
|
||||
data: f.dataUrl.slice(f.dataUrl.indexOf(',') + 1),
|
||||
mediaType: 'image/jpeg',
|
||||
capturedAt: f.capturedAt,
|
||||
dataUrl: f.dataUrl,
|
||||
source,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useVideoMode() {
|
||||
const [state, setState] = useState<VideoModeState>('idle');
|
||||
const [screenState, setScreenState] = useState<ScreenShareState>('idle');
|
||||
// Camera can be turned off mid-session (Meet-style) while the mode — and
|
||||
// any screen share — keeps running. Resets to on for the next session.
|
||||
const [cameraOn, setCameraOn] = useState(true);
|
||||
// In-call mute pauses capture entirely: nothing lands in the ring buffers
|
||||
// and collectFrames() returns nothing, so a muted stretch can never ride
|
||||
// along with a later message. Streams stay open for instant resume.
|
||||
const capturePausedRef = useRef(false);
|
||||
const cameraPipeRef = useRef<CapturePipe>(emptyPipe());
|
||||
const screenPipeRef = useRef<CapturePipe>(emptyPipe());
|
||||
// Stable stream refs for preview components (<video srcObject>).
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const stateRef = useRef<VideoModeState>('idle');
|
||||
stateRef.current = state;
|
||||
const screenStateRef = useRef<ScreenShareState>('idle');
|
||||
screenStateRef.current = screenState;
|
||||
|
||||
const captureCameraFrame = useCallback(() => {
|
||||
if (capturePausedRef.current) return;
|
||||
capturePipeFrame(cameraPipeRef.current, CAMERA_FRAME_WIDTH, CAMERA_JPEG_QUALITY);
|
||||
}, []);
|
||||
|
||||
const captureScreenFrame = useCallback(() => {
|
||||
if (capturePausedRef.current) return;
|
||||
capturePipeFrame(screenPipeRef.current, SCREEN_FRAME_WIDTH, SCREEN_JPEG_QUALITY);
|
||||
}, []);
|
||||
|
||||
const setCapturePaused = useCallback((paused: boolean) => {
|
||||
capturePausedRef.current = paused;
|
||||
}, []);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
teardownPipe(screenPipeRef.current);
|
||||
screenStreamRef.current = null;
|
||||
setScreenState('idle');
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
teardownPipe(cameraPipeRef.current);
|
||||
streamRef.current = null;
|
||||
setState('idle');
|
||||
setCameraOn(true);
|
||||
capturePausedRef.current = false;
|
||||
stopScreenShare();
|
||||
}, [stopScreenShare]);
|
||||
|
||||
// Acquire the webcam and start its capture pipeline. Shared by start()
|
||||
// and by re-enabling the camera mid-session.
|
||||
const acquireCamera = useCallback(async (): Promise<boolean> => {
|
||||
// Settle the macOS TCC camera permission before getUserMedia, same as
|
||||
// voice mode does for the mic — otherwise the first click silently
|
||||
// fails while the native prompt is still up.
|
||||
const access = await window.ipc
|
||||
.invoke('voice:ensureCameraAccess', null)
|
||||
.catch(() => ({ granted: true }));
|
||||
if (!access.granted) {
|
||||
console.error('[video] Camera access denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
|
||||
audio: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[video] Camera access failed:', err);
|
||||
return false;
|
||||
}
|
||||
|
||||
streamRef.current = stream;
|
||||
attachPipeSource(cameraPipeRef.current, stream, captureCameraFrame);
|
||||
return true;
|
||||
}, [captureCameraFrame]);
|
||||
|
||||
/**
|
||||
* Turn the camera off/on without leaving video mode (Meet-style). While
|
||||
* off, no webcam frames are captured or attached; screen-share frames
|
||||
* (if presenting) keep flowing.
|
||||
*/
|
||||
const setCameraEnabled = useCallback(async (enabled: boolean): Promise<boolean> => {
|
||||
if (stateRef.current !== 'live') return false;
|
||||
if (enabled) {
|
||||
const ok = await acquireCamera();
|
||||
if (ok) setCameraOn(true);
|
||||
return ok;
|
||||
}
|
||||
teardownPipe(cameraPipeRef.current);
|
||||
streamRef.current = null;
|
||||
setCameraOn(false);
|
||||
return true;
|
||||
}, [acquireCamera]);
|
||||
|
||||
/**
|
||||
* Start video mode. `camera: false` starts a camera-less session (voice
|
||||
* call / screen-share-only) — the mode is live so frames can flow from
|
||||
* other sources, and the camera can be enabled later via setCameraEnabled.
|
||||
*/
|
||||
const start = useCallback(async ({ camera = true }: { camera?: boolean } = {}): Promise<boolean> => {
|
||||
if (stateRef.current !== 'idle') return true;
|
||||
setState('starting');
|
||||
if (camera) {
|
||||
const ok = await acquireCamera();
|
||||
if (!ok) {
|
||||
setState('idle');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setCameraOn(camera);
|
||||
setState('live');
|
||||
return true;
|
||||
}, [acquireCamera]);
|
||||
|
||||
/**
|
||||
* Share the screen. The main process auto-approves getDisplayMedia with
|
||||
* the primary screen (see setDisplayMediaRequestHandler in main.ts), so
|
||||
* no source picker appears. Returns false if capture couldn't start
|
||||
* (usually the macOS Screen Recording permission).
|
||||
*/
|
||||
const startScreenShare = useCallback(async (): Promise<boolean> => {
|
||||
if (screenStateRef.current !== 'idle') return true;
|
||||
setScreenState('starting');
|
||||
|
||||
// Surfaces the macOS Screen Recording permission state and, on first
|
||||
// use, registers the app in System Settings (same flow meetings use).
|
||||
await window.ipc.invoke('meeting:checkScreenPermission', null).catch(() => null);
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: { ideal: 5 } },
|
||||
audio: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[video] Screen share failed:', err);
|
||||
setScreenState('idle');
|
||||
return false;
|
||||
}
|
||||
|
||||
screenStreamRef.current = stream;
|
||||
// The capture can end outside our UI (display unplugged, OS revokes) —
|
||||
// tear down cleanly so the UI doesn't show a dead share.
|
||||
stream.getVideoTracks()[0]?.addEventListener('ended', () => stopScreenShare(), { once: true });
|
||||
attachPipeSource(screenPipeRef.current, stream, captureScreenFrame);
|
||||
setScreenState('live');
|
||||
return true;
|
||||
}, [captureScreenFrame, stopScreenShare]);
|
||||
|
||||
/**
|
||||
* Drain webcam + screen-share frames buffered since the last send, tagged
|
||||
* by source. Webcam frames come first, then screen frames.
|
||||
*/
|
||||
const collectFrames = useCallback((): CapturedVideoFrame[] => {
|
||||
if (stateRef.current !== 'live') return [];
|
||||
// Muted: no frames at all — not even pre-mute buffered ones — so a
|
||||
// typed message during a mute carries nothing captured around it.
|
||||
if (capturePausedRef.current) return [];
|
||||
// Grab a frame right now so the message always includes the moment of send.
|
||||
captureCameraFrame();
|
||||
const frames = drainPipe(cameraPipeRef.current, MAX_CAMERA_FRAMES_PER_MESSAGE, 'camera');
|
||||
if (screenStateRef.current === 'live') {
|
||||
captureScreenFrame();
|
||||
frames.push(...drainPipe(screenPipeRef.current, MAX_SCREEN_FRAMES_PER_MESSAGE, 'screen'));
|
||||
}
|
||||
return frames;
|
||||
}, [captureCameraFrame, captureScreenFrame]);
|
||||
|
||||
// Release the camera/screen if the component unmounts with video mode on.
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
return { state, screenState, cameraOn, streamRef, screenStreamRef, start, stop, startScreenShare, stopScreenShare, setCameraEnabled, setCapturePaused, collectFrames };
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening';
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening' | 'submitting';
|
||||
|
||||
const DEEPGRAM_PARAMS = new URLSearchParams({
|
||||
model: 'nova-3',
|
||||
|
|
@ -18,7 +19,35 @@ const DEEPGRAM_PARAMS = new URLSearchParams({
|
|||
endpointing: '100',
|
||||
no_delay: 'true',
|
||||
});
|
||||
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
|
||||
// Hands-free (continuous) mode: Deepgram's endpoint fires FAST (600ms of
|
||||
// silence) and we apply smart hold logic on our side — if the transcript
|
||||
// already reads as a complete thought (terminal punctuation) the utterance
|
||||
// fires immediately, otherwise we hold INCOMPLETE_HOLD_MS longer in case the
|
||||
// user was mid-thought. Net effect: complete sentences turn around ~1.2s
|
||||
// faster than the old fixed 1800ms endpoint, while thinking pauses still get
|
||||
// the same total grace (~1.8s).
|
||||
const CONTINUOUS_ENDPOINTING_MS = 600;
|
||||
const INCOMPLETE_HOLD_MS = 1200;
|
||||
// While the mic is paused (assistant speaking), keep the idle Deepgram socket
|
||||
// alive — it closes after ~10s without audio otherwise.
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
// Deepgram punctuates finals (punctuate=true) — a transcript ending in
|
||||
// terminal punctuation (optionally inside a closing quote/paren) is treated
|
||||
// as a complete thought.
|
||||
const COMPLETE_THOUGHT_RE = /[.!?…]["')\]]*\s*$/;
|
||||
|
||||
function deepgramParams(continuous: boolean): URLSearchParams {
|
||||
if (!continuous) return DEEPGRAM_PARAMS;
|
||||
const params = new URLSearchParams(DEEPGRAM_PARAMS);
|
||||
params.set('endpointing', String(CONTINUOUS_ENDPOINTING_MS));
|
||||
// Second end-of-speech signal: speech_final can be missed (it often rides
|
||||
// on a result with an empty transcript, or never fires when background
|
||||
// noise keeps the endpointer engaged). UtteranceEnd is word-timing based
|
||||
// and arrives as its own message type, so we listen for both.
|
||||
params.set('utterance_end_ms', '1000');
|
||||
return params;
|
||||
}
|
||||
|
||||
// Cap on retained per-frame amplitude samples (~64ms/frame ⇒ ~5 min of history).
|
||||
// The waveform only ever displays the most recent window, so older samples are dropped.
|
||||
|
|
@ -52,6 +81,13 @@ export function useVoiceMode() {
|
|||
const audioLevelsRef = useRef<number[]>([]);
|
||||
// Running peak amplitude for the waveform auto-gain (see PEAK_DECAY/MIN_PEAK).
|
||||
const audioPeakRef = useRef(0);
|
||||
// Hands-free mode: invoked with each completed utterance (speech_final).
|
||||
const continuousCbRef = useRef<((text: string) => void) | null>(null);
|
||||
// While true (assistant is speaking), mic audio is dropped instead of streamed.
|
||||
const pausedRef = useRef(false);
|
||||
const keepAliveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Pending mid-thought hold (smart endpointing) — see maybeEndUtterance.
|
||||
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Refresh cached auth details (called on warmup, not on mic click)
|
||||
const refreshAuth = useCallback(async () => {
|
||||
|
|
@ -70,9 +106,44 @@ export function useVoiceMode() {
|
|||
}
|
||||
}, [refreshRowboatAccount]);
|
||||
|
||||
// Hands-free mode: flush the accumulated utterance to the callback.
|
||||
// Both end-of-speech signals may fire for the same utterance — the second
|
||||
// finds an empty buffer and is a no-op.
|
||||
const fireContinuousUtterance = useCallback(() => {
|
||||
if (holdTimerRef.current) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
if (!continuousCbRef.current || pausedRef.current) return;
|
||||
const utterance = transcriptBufferRef.current.trim();
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setInterimText('');
|
||||
if (utterance) continuousCbRef.current(utterance);
|
||||
}, []);
|
||||
|
||||
// Smart endpoint: Deepgram's endpoint fires fast (600ms). If the
|
||||
// transcript reads as a complete thought, hand it off immediately; if it
|
||||
// trails off mid-sentence ("so what I want is…"), hold a little longer —
|
||||
// resumed speech cancels the hold and the utterance keeps growing.
|
||||
const maybeEndUtterance = useCallback(() => {
|
||||
if (!continuousCbRef.current || pausedRef.current) return;
|
||||
const buffered = transcriptBufferRef.current.trim();
|
||||
if (!buffered) return;
|
||||
if (COMPLETE_THOUGHT_RE.test(buffered)) {
|
||||
fireContinuousUtterance();
|
||||
return;
|
||||
}
|
||||
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = setTimeout(() => {
|
||||
holdTimerRef.current = null;
|
||||
fireContinuousUtterance();
|
||||
}, INCOMPLETE_HOLD_MS);
|
||||
}, [fireContinuousUtterance]);
|
||||
|
||||
// Create and connect a Deepgram WebSocket using cached auth.
|
||||
// Starts the connection and returns immediately (does not wait for open).
|
||||
const connectWs = useCallback(async () => {
|
||||
const connectWs = useCallback(async (continuous = false) => {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
|
||||
|
||||
// Refresh auth if we don't have it cached yet
|
||||
|
|
@ -81,12 +152,13 @@ export function useVoiceMode() {
|
|||
}
|
||||
if (!cachedAuth) return;
|
||||
|
||||
const params = deepgramParams(continuous);
|
||||
let ws: WebSocket;
|
||||
if (cachedAuth.type === 'rowboat') {
|
||||
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, DEEPGRAM_PARAMS);
|
||||
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, params);
|
||||
ws = new WebSocket(listenUrl, ['bearer', cachedAuth.token]);
|
||||
} else {
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', cachedAuth.apiKey]);
|
||||
ws = new WebSocket(`wss://api.deepgram.com/v1/listen?${params.toString()}`, ['token', cachedAuth.apiKey]);
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
|
|
@ -102,16 +174,43 @@ export function useVoiceMode() {
|
|||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (!data.channel?.alternatives?.[0]) return;
|
||||
|
||||
// Hands-free mode: word-timing based end-of-speech marker.
|
||||
if (data.type === 'UtteranceEnd') {
|
||||
maybeEndUtterance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.channel?.alternatives?.[0]) return;
|
||||
const transcript = data.channel.alternatives[0].transcript;
|
||||
if (!transcript) return;
|
||||
|
||||
// The user resumed speaking — cancel any pending mid-thought hold
|
||||
// so the utterance keeps growing instead of firing under them.
|
||||
if (transcript && holdTimerRef.current) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (data.is_final) {
|
||||
transcriptBufferRef.current += (transcriptBufferRef.current ? ' ' : '') + transcript;
|
||||
interimRef.current = '';
|
||||
setInterimText(transcriptBufferRef.current);
|
||||
} else {
|
||||
// NOTE: the endpoint marker (speech_final) usually arrives on a
|
||||
// result whose transcript is EMPTY — the silence after the user
|
||||
// stops talking. Empty finals must still reach the speech_final
|
||||
// check below or hands-free utterances never complete.
|
||||
if (transcript) {
|
||||
transcriptBufferRef.current += (transcriptBufferRef.current ? ' ' : '') + transcript;
|
||||
interimRef.current = '';
|
||||
}
|
||||
// Hands-free mode: an endpoint may complete the utterance —
|
||||
// immediately for complete thoughts, after a short hold for
|
||||
// mid-sentence trails.
|
||||
if (continuousCbRef.current && data.speech_final) {
|
||||
maybeEndUtterance();
|
||||
return;
|
||||
}
|
||||
if (transcript) {
|
||||
setInterimText(transcriptBufferRef.current);
|
||||
}
|
||||
} else if (transcript) {
|
||||
interimRef.current = transcript;
|
||||
setInterimText(transcriptBufferRef.current + (transcriptBufferRef.current ? ' ' : '') + transcript);
|
||||
}
|
||||
|
|
@ -126,11 +225,57 @@ export function useVoiceMode() {
|
|||
ws.onclose = () => {
|
||||
console.log('[voice] WebSocket closed');
|
||||
wsRef.current = null;
|
||||
// A hands-free call is long-lived — if the socket drops while the
|
||||
// call is still on, reconnect instead of silently going deaf.
|
||||
if (continuousCbRef.current) {
|
||||
setTimeout(() => {
|
||||
if (continuousCbRef.current && !wsRef.current) {
|
||||
void connectWs(true);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
}, [refreshAuth]);
|
||||
}, [refreshAuth, maybeEndUtterance]);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
const waitForWsOpen = useCallback(async (timeoutMs = 1500): Promise<boolean> => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws) return false;
|
||||
if (ws.readyState === WebSocket.OPEN) return true;
|
||||
if (ws.readyState !== WebSocket.CONNECTING) return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const finish = (ok: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('open', onOpen);
|
||||
ws.removeEventListener('error', onError);
|
||||
ws.removeEventListener('close', onClose);
|
||||
resolve(ok);
|
||||
};
|
||||
const onOpen = () => finish(true);
|
||||
const onError = () => finish(false);
|
||||
const onClose = () => finish(false);
|
||||
timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
ws.addEventListener('open', onOpen);
|
||||
ws.addEventListener('error', onError);
|
||||
ws.addEventListener('close', onClose);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flushBufferedAudio = useCallback(() => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const buffered = audioBufferRef.current;
|
||||
audioBufferRef.current = [];
|
||||
for (const chunk of buffered) {
|
||||
ws.send(chunk);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopInputCapture = useCallback(() => {
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
|
|
@ -143,11 +288,26 @@ export function useVoiceMode() {
|
|||
mediaStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
stopInputCapture();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
continuousCbRef.current = null;
|
||||
pausedRef.current = false;
|
||||
if (holdTimerRef.current) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
if (keepAliveTimerRef.current) {
|
||||
clearInterval(keepAliveTimerRef.current);
|
||||
keepAliveTimerRef.current = null;
|
||||
}
|
||||
audioBufferRef.current = [];
|
||||
audioLevelsRef.current = [];
|
||||
audioPeakRef.current = 0;
|
||||
|
|
@ -155,9 +315,9 @@ export function useVoiceMode() {
|
|||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setState('idle');
|
||||
}, []);
|
||||
}, [stopInputCapture]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const start = useCallback(async (continuous = false) => {
|
||||
if (state !== 'idle') return;
|
||||
|
||||
transcriptBufferRef.current = '';
|
||||
|
|
@ -192,7 +352,7 @@ export function useVoiceMode() {
|
|||
console.error('Microphone access denied:', err);
|
||||
return null;
|
||||
}),
|
||||
connectWs(),
|
||||
connectWs(continuous),
|
||||
]);
|
||||
|
||||
if (!stream) {
|
||||
|
|
@ -216,6 +376,9 @@ export function useVoiceMode() {
|
|||
processorRef.current = processor;
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
// Paused (assistant is speaking in a call): drop mic audio so the
|
||||
// assistant's own TTS never gets transcribed back at it.
|
||||
if (pausedRef.current) return;
|
||||
const float32 = e.inputBuffer.getChannelData(0);
|
||||
const int16 = new Int16Array(float32.length);
|
||||
let sumSquares = 0;
|
||||
|
|
@ -240,8 +403,13 @@ export function useVoiceMode() {
|
|||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(buffer);
|
||||
} else {
|
||||
// WebSocket still connecting — buffer the audio
|
||||
// WebSocket still connecting (or reconnecting mid-call) —
|
||||
// buffer the audio, bounded so an unreachable server during a
|
||||
// long call can't grow it without limit (~30s at 64ms/chunk).
|
||||
audioBufferRef.current.push(buffer);
|
||||
if (audioBufferRef.current.length > 500) {
|
||||
audioBufferRef.current.shift();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -250,25 +418,71 @@ export function useVoiceMode() {
|
|||
}, [state, connectWs, stopAudioCapture]);
|
||||
|
||||
/** Stop recording and return the full transcript (finalized + any current interim) */
|
||||
const submit = useCallback((): string => {
|
||||
const submit = useCallback(async (): Promise<string> => {
|
||||
setState('submitting');
|
||||
stopInputCapture();
|
||||
|
||||
if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||
await waitForWsOpen();
|
||||
}
|
||||
flushBufferedAudio();
|
||||
await finalizeDeepgramStream(wsRef.current);
|
||||
|
||||
let text = transcriptBufferRef.current;
|
||||
if (interimRef.current) {
|
||||
text += (text ? ' ' : '') + interimRef.current;
|
||||
}
|
||||
text = text.trim();
|
||||
|
||||
stopAudioCapture();
|
||||
return text;
|
||||
}, [stopAudioCapture]);
|
||||
}, [flushBufferedAudio, stopAudioCapture, stopInputCapture, waitForWsOpen]);
|
||||
|
||||
/** Cancel recording without returning transcript */
|
||||
const cancel = useCallback(() => {
|
||||
stopAudioCapture();
|
||||
}, [stopAudioCapture]);
|
||||
|
||||
/**
|
||||
* Hands-free (call) mode: listen continuously and invoke `onUtterance`
|
||||
* with each completed utterance. Runs until cancel()/stop.
|
||||
*/
|
||||
const startContinuous = useCallback(async (onUtterance: (text: string) => void) => {
|
||||
continuousCbRef.current = onUtterance;
|
||||
await start(true);
|
||||
}, [start]);
|
||||
|
||||
/**
|
||||
* Mute/unmute the continuous stream (used while the assistant is
|
||||
* thinking/speaking). Keeps the Deepgram socket alive with KeepAlives and
|
||||
* discards any half-heard utterance from before the pause.
|
||||
*/
|
||||
const setPaused = useCallback((paused: boolean) => {
|
||||
if (pausedRef.current === paused) return;
|
||||
pausedRef.current = paused;
|
||||
if (paused) {
|
||||
if (holdTimerRef.current) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setInterimText('');
|
||||
keepAliveTimerRef.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'KeepAlive' }));
|
||||
}
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
} else if (keepAliveTimerRef.current) {
|
||||
clearInterval(keepAliveTimerRef.current);
|
||||
keepAliveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Pre-cache auth details so mic click skips IPC round-trips */
|
||||
const warmup = useCallback(() => {
|
||||
refreshAuth().catch(() => {});
|
||||
}, [refreshAuth]);
|
||||
|
||||
return { state, interimText, audioLevelsRef, start, submit, cancel, warmup };
|
||||
return { state, interimText, audioLevelsRef, start, submit, cancel, warmup, startContinuous, setPaused };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { dispatchCreditReplenished } from '@/lib/credit-status';
|
||||
|
||||
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
|
||||
|
|
@ -18,14 +18,23 @@ function synthesize(text: string): Promise<SynthesizedAudio> {
|
|||
);
|
||||
}
|
||||
|
||||
function playAudio(dataUrl: string, audioRef: React.MutableRefObject<HTMLAudioElement | null>): Promise<void> {
|
||||
function playAudio(
|
||||
dataUrl: string,
|
||||
audioRef: React.MutableRefObject<HTMLAudioElement | null>,
|
||||
onAudioElement?: (audio: HTMLAudioElement) => void
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(dataUrl);
|
||||
audioRef.current = audio;
|
||||
onAudioElement?.(audio);
|
||||
audio.onended = () => {
|
||||
console.log('[tts] audio ended');
|
||||
resolve();
|
||||
};
|
||||
// pause() (from cancel) must settle this promise too, or the queue
|
||||
// loop stays parked on it forever. Natural end also fires 'pause'
|
||||
// just before 'ended'; double-resolve is harmless.
|
||||
audio.onpause = () => resolve();
|
||||
audio.onerror = (e) => {
|
||||
console.error('[tts] audio error:', e);
|
||||
reject(new Error('Audio playback failed'));
|
||||
|
|
@ -39,47 +48,282 @@ function playAudio(dataUrl: string, audioRef: React.MutableRefObject<HTMLAudioEl
|
|||
});
|
||||
}
|
||||
|
||||
/** A queue entry: text to synthesize, or a ready-to-play audio URL (e.g. a bundled clip). */
|
||||
type QueueItem = { text: string } | { url: string };
|
||||
|
||||
type TtsChunkMsg = { requestId: string; chunkBase64?: string; done: boolean; error?: string };
|
||||
|
||||
export function useVoiceTTS() {
|
||||
const [state, setState] = useState<TTSState>('idle');
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const queueRef = useRef<string[]>([]);
|
||||
const queueRef = useRef<QueueItem[]>([]);
|
||||
const processingRef = useRef(false);
|
||||
// Pre-fetched audio ready to play immediately
|
||||
const prefetchedRef = useRef<Promise<SynthesizedAudio> | null>(null);
|
||||
// Streaming synthesis: per-request chunk handlers + the in-flight request
|
||||
// id (so cancel() can abort the main-process fetch).
|
||||
const streamHandlersRef = useRef<Map<string, (msg: TtsChunkMsg) => void>>(new Map());
|
||||
const activeStreamIdRef = useRef<string | null>(null);
|
||||
// Bumped by cancel(). A queue loop that awaited across a cancel sees a
|
||||
// stale generation and exits instead of playing audio that was cancelled
|
||||
// while still synthesizing (which would overlap the next utterance).
|
||||
const generationRef = useRef(0);
|
||||
// Web Audio analyser tap for lip-sync (talking head)
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const levelBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
||||
|
||||
// Route playback through an AnalyserNode so consumers can read the live
|
||||
// output level. If Web Audio wiring fails, the element still plays directly.
|
||||
const connectAnalyser = useCallback((audio: HTMLAudioElement) => {
|
||||
try {
|
||||
let ctx = audioCtxRef.current;
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext();
|
||||
audioCtxRef.current = ctx;
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
analyser.connect(ctx.destination);
|
||||
analyserRef.current = analyser;
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume();
|
||||
}
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
source.connect(analyserRef.current!);
|
||||
// Detach once this chunk is done (ended, cancelled via pause, or
|
||||
// failed) so source nodes don't accumulate over a long session.
|
||||
const disconnect = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
} catch {
|
||||
// already disconnected
|
||||
}
|
||||
};
|
||||
audio.addEventListener('ended', disconnect, { once: true });
|
||||
audio.addEventListener('pause', disconnect, { once: true });
|
||||
audio.addEventListener('error', disconnect, { once: true });
|
||||
} catch (err) {
|
||||
console.error('[tts] analyser hookup failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Current output level, 0..1. Safe to call every animation frame.
|
||||
// Release the audio graph when the owning component unmounts
|
||||
useEffect(() => () => {
|
||||
audioCtxRef.current?.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Route streaming TTS chunks to whichever request is waiting for them.
|
||||
useEffect(() => {
|
||||
return window.ipc.on('voice:tts-chunk', (msg) => {
|
||||
streamHandlersRef.current.get(msg.requestId)?.(msg);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Streaming synthesis + playback via MediaSource: audio starts on the
|
||||
* first chunk instead of after the full body. Rejects (for caller
|
||||
* fallback to non-streaming synth) if the stream fails before any audio
|
||||
* arrived; resolves when playback finishes.
|
||||
*/
|
||||
const streamSynthesizeAndPlay = useCallback((text: string, onStarted: () => void): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported('audio/mpeg')) {
|
||||
reject(new Error('MSE audio/mpeg unsupported'));
|
||||
return;
|
||||
}
|
||||
const requestId = `tts-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const mediaSource = new MediaSource();
|
||||
const audio = new Audio();
|
||||
audio.src = URL.createObjectURL(mediaSource);
|
||||
audioRef.current = audio;
|
||||
connectAnalyser(audio);
|
||||
activeStreamIdRef.current = requestId;
|
||||
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
const pending: Uint8Array[] = [];
|
||||
let streamDone = false;
|
||||
let gotAudio = false;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
streamHandlersRef.current.delete(requestId);
|
||||
if (activeStreamIdRef.current === requestId) activeStreamIdRef.current = null;
|
||||
URL.revokeObjectURL(audio.src);
|
||||
};
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
// Drain pending chunks into the SourceBuffer one at a time
|
||||
// (appendBuffer is async; only one append may be in flight).
|
||||
const pump = () => {
|
||||
if (!sourceBuffer || sourceBuffer.updating || settled) return;
|
||||
const chunk = pending.shift();
|
||||
if (chunk) {
|
||||
try {
|
||||
sourceBuffer.appendBuffer(chunk as BufferSource);
|
||||
} catch (e) {
|
||||
finish(e as Error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (streamDone && mediaSource.readyState === 'open') {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch { /* already ended */ }
|
||||
}
|
||||
};
|
||||
|
||||
mediaSource.addEventListener('sourceopen', () => {
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
|
||||
} catch (e) {
|
||||
finish(e as Error);
|
||||
return;
|
||||
}
|
||||
sourceBuffer.addEventListener('updateend', pump);
|
||||
pump();
|
||||
}, { once: true });
|
||||
|
||||
streamHandlersRef.current.set(requestId, (msg) => {
|
||||
if (msg.error && !gotAudio) {
|
||||
streamDone = true;
|
||||
finish(new Error(msg.error));
|
||||
return;
|
||||
}
|
||||
if (msg.chunkBase64) {
|
||||
gotAudio = true;
|
||||
const bin = atob(msg.chunkBase64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
pending.push(bytes);
|
||||
pump();
|
||||
}
|
||||
if (msg.done) {
|
||||
streamDone = true;
|
||||
pump();
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('playing', () => onStarted(), { once: true });
|
||||
audio.onended = () => finish();
|
||||
// pause() (from cancel) must settle this promise too; natural end
|
||||
// also fires 'pause' just before 'ended'; double-settle is a no-op.
|
||||
audio.onpause = () => finish();
|
||||
audio.onerror = () => finish(new Error('stream playback failed'));
|
||||
|
||||
window.ipc
|
||||
.invoke('voice:synthesizeStreamStart', { requestId, text })
|
||||
.then((res) => {
|
||||
if (!res.ok) finish(new Error(res.error || 'stream start failed'));
|
||||
})
|
||||
.catch((e) => finish(e as Error));
|
||||
|
||||
// Starts as soon as the first appended data is decodable.
|
||||
audio.play().catch(() => { /* surfaced via onerror / chunk error */ });
|
||||
|
||||
// Nothing arrived at all — bail so the caller can fall back.
|
||||
setTimeout(() => {
|
||||
if (!gotAudio && !settled) finish(new Error('stream timeout'));
|
||||
}, 10_000);
|
||||
});
|
||||
}, [connectAnalyser]);
|
||||
|
||||
const getLevel = useCallback((): number => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return 0;
|
||||
let buffer = levelBufferRef.current;
|
||||
if (!buffer || buffer.length !== analyser.fftSize) {
|
||||
buffer = new Uint8Array(analyser.fftSize);
|
||||
levelBufferRef.current = buffer;
|
||||
}
|
||||
analyser.getByteTimeDomainData(buffer);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const d = (buffer[i] - 128) / 128;
|
||||
sum += d * d;
|
||||
}
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
return Math.min(1, rms * 4);
|
||||
}, []);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
const gen = generationRef.current;
|
||||
|
||||
// Kick off full-body pre-fetch for the next queued text while the
|
||||
// current one plays — keeps sentence-to-sentence playback gapless.
|
||||
const prefetchNext = () => {
|
||||
const next = queueRef.current[0];
|
||||
if (next && 'text' in next && next.text.trim() && !prefetchedRef.current) {
|
||||
console.log('[tts] pre-fetching next:', next.text.substring(0, 80));
|
||||
prefetchedRef.current = synthesize(next.text);
|
||||
}
|
||||
};
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
const text = queueRef.current.shift()!;
|
||||
if (!text.trim()) continue;
|
||||
const item = queueRef.current.shift()!;
|
||||
if ('text' in item && !item.text.trim()) continue;
|
||||
|
||||
// Cold start (nothing playing, nothing pre-fetched): stream the
|
||||
// synthesis so audio begins on the first chunk instead of after
|
||||
// the full body — this is where first-response latency lives.
|
||||
if ('text' in item && !prefetchedRef.current) {
|
||||
setState('synthesizing');
|
||||
console.log('[tts] stream-synthesizing:', item.text.substring(0, 80));
|
||||
try {
|
||||
await streamSynthesizeAndPlay(item.text, () => {
|
||||
if (generationRef.current !== gen) return;
|
||||
setState('speaking');
|
||||
prefetchNext();
|
||||
});
|
||||
if (generationRef.current !== gen) return;
|
||||
continue;
|
||||
} catch (err) {
|
||||
if (generationRef.current !== gen) return;
|
||||
console.error('[tts] stream failed, falling back to full synth:', err);
|
||||
// fall through to the non-streaming path below
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use pre-fetched result if available, otherwise synthesize now
|
||||
// Pre-recorded URL plays as-is; text uses the pre-fetched
|
||||
// result if available, otherwise synthesizes now.
|
||||
let audioPromise: Promise<SynthesizedAudio>;
|
||||
if (prefetchedRef.current) {
|
||||
if ('url' in item) {
|
||||
audioPromise = Promise.resolve({ dataUrl: item.url });
|
||||
} else if (prefetchedRef.current) {
|
||||
console.log('[tts] using pre-fetched audio');
|
||||
audioPromise = prefetchedRef.current;
|
||||
prefetchedRef.current = null;
|
||||
} else {
|
||||
setState('synthesizing');
|
||||
console.log('[tts] synthesizing:', text.substring(0, 80));
|
||||
audioPromise = synthesize(text);
|
||||
console.log('[tts] synthesizing:', item.text.substring(0, 80));
|
||||
audioPromise = synthesize(item.text);
|
||||
}
|
||||
|
||||
const audio = await audioPromise;
|
||||
// Cancelled while synthesizing — cancel() already reset all
|
||||
// state (and a new loop may be running), so just bail.
|
||||
if (generationRef.current !== gen) return;
|
||||
setState('speaking');
|
||||
|
||||
// Kick off pre-fetch for next chunk while this one plays
|
||||
const nextText = queueRef.current[0];
|
||||
if (nextText?.trim()) {
|
||||
console.log('[tts] pre-fetching next:', nextText.substring(0, 80));
|
||||
prefetchedRef.current = synthesize(nextText);
|
||||
}
|
||||
prefetchNext();
|
||||
|
||||
await playAudio(audio.dataUrl, audioRef);
|
||||
await playAudio(audio.dataUrl, audioRef, connectAnalyser);
|
||||
if (generationRef.current !== gen) return;
|
||||
} catch (err) {
|
||||
if (generationRef.current !== gen) return;
|
||||
console.error('[tts] error:', err);
|
||||
prefetchedRef.current = null;
|
||||
}
|
||||
|
|
@ -89,17 +333,33 @@ export function useVoiceTTS() {
|
|||
prefetchedRef.current = null;
|
||||
processingRef.current = false;
|
||||
setState('idle');
|
||||
}, []);
|
||||
}, [connectAnalyser, streamSynthesizeAndPlay]);
|
||||
|
||||
const speak = useCallback((text: string) => {
|
||||
console.log('[tts] speak() called:', text.substring(0, 80));
|
||||
queueRef.current.push(text);
|
||||
queueRef.current.push({ text });
|
||||
processQueue();
|
||||
}, [processQueue]);
|
||||
|
||||
// Play a pre-recorded clip (e.g. bundled tour narration) through the same
|
||||
// queue, so lip-sync levels, state, and cancel() all work unchanged.
|
||||
const speakUrl = useCallback((url: string) => {
|
||||
console.log('[tts] speakUrl() called:', url.substring(0, 120));
|
||||
queueRef.current.push({ url });
|
||||
processQueue();
|
||||
}, [processQueue]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
generationRef.current++;
|
||||
queueRef.current = [];
|
||||
prefetchedRef.current = null;
|
||||
// Abort any in-flight streaming synthesis in the main process.
|
||||
if (activeStreamIdRef.current) {
|
||||
void window.ipc
|
||||
.invoke('voice:synthesizeStreamCancel', { requestId: activeStreamIdRef.current })
|
||||
.catch(() => {});
|
||||
activeStreamIdRef.current = null;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
|
|
@ -108,5 +368,5 @@ export function useVoiceTTS() {
|
|||
setState('idle');
|
||||
}, []);
|
||||
|
||||
return { state, speak, cancel };
|
||||
return { state, speak, speakUrl, cancel, getLevel };
|
||||
}
|
||||
|
|
|
|||
28
apps/x/apps/renderer/src/lib/agent-transcript.test.ts
Normal file
28
apps/x/apps/renderer/src/lib/agent-transcript.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { turnToTranscript } from './agent-transcript'
|
||||
import { completedTurnLog, created, requested, user } from './session-chat/test-fixtures'
|
||||
|
||||
describe('turnToTranscript', () => {
|
||||
it('maps a completed turn to id/createdAt/summary/items', () => {
|
||||
const events = completedTurnLog('turn-1', 'sess-1', 'do the thing', 'all done')
|
||||
const t = turnToTranscript('turn-1', events)
|
||||
expect(t.id).toBe('turn-1')
|
||||
expect(t.createdAt).toBeDefined()
|
||||
expect(t.summary).toBe('all done')
|
||||
expect(t.error).toBeUndefined()
|
||||
expect(t.trigger).toBeUndefined()
|
||||
expect(t.items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('surfaces turn_failed as error with no summary', () => {
|
||||
const events = [
|
||||
created('turn-2', 'sess-1', user('go')),
|
||||
requested('turn-2', 0),
|
||||
{ type: 'model_call_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', error: 'boom', usage: {} },
|
||||
]
|
||||
const t = turnToTranscript('turn-2', events)
|
||||
expect(t.error).toBe('boom')
|
||||
expect(t.summary).toBeUndefined()
|
||||
})
|
||||
})
|
||||
89
apps/x/apps/renderer/src/lib/agent-transcript.ts
Normal file
89
apps/x/apps/renderer/src/lib/agent-transcript.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { z } from 'zod'
|
||||
import type { Run } from '@x/shared/src/runs.js'
|
||||
import { reduceTurn, type TurnEvent } from '@x/shared/src/turns.js'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { buildTurnConversation } from '@/lib/session-chat/turn-view'
|
||||
|
||||
// Unified read model for a headless agent run's transcript, whether the id
|
||||
// is a turn (new runtime) or a legacy run (pre-turn-runtime files under
|
||||
// $WorkDir/runs/). Used by the background-tasks and live-note history views.
|
||||
|
||||
export interface AgentRunTranscript {
|
||||
id: string
|
||||
createdAt?: string
|
||||
// Legacy runs only (subUseCase); turns carry the trigger inside the
|
||||
// message text instead.
|
||||
trigger?: string
|
||||
summary?: string
|
||||
error?: string
|
||||
items: ConversationItem[]
|
||||
}
|
||||
|
||||
export function turnToTranscript(
|
||||
turnId: string,
|
||||
events: Array<z.infer<typeof TurnEvent>>,
|
||||
): AgentRunTranscript {
|
||||
const state = reduceTurn(events)
|
||||
const out: AgentRunTranscript = {
|
||||
id: turnId,
|
||||
createdAt: state.definition.ts,
|
||||
items: buildTurnConversation(state),
|
||||
}
|
||||
if (state.terminal?.type === 'turn_failed') {
|
||||
out.error = state.terminal.error
|
||||
}
|
||||
for (let i = state.modelCalls.length - 1; i >= 0; i--) {
|
||||
const response = state.modelCalls[i].response
|
||||
if (!response) continue
|
||||
const content = response.content
|
||||
const text =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.map((p) => (p.type === 'text' ? p.text : '')).join('')
|
||||
if (text) {
|
||||
out.summary = text
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function runToTranscript(run: z.infer<typeof Run>): AgentRunTranscript {
|
||||
const out: AgentRunTranscript = {
|
||||
id: run.id,
|
||||
createdAt: run.createdAt,
|
||||
trigger: run.subUseCase,
|
||||
items: runLogToConversation(run.log),
|
||||
}
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Turn-first with a legacy-run fallback so histories recorded before the
|
||||
// turn-runtime migration stay readable. The fallback goes away with the
|
||||
// runs runtime (stage 7).
|
||||
export async function fetchAgentRunTranscript(id: string): Promise<AgentRunTranscript> {
|
||||
try {
|
||||
const turn = await window.ipc.invoke('sessions:getTurn', { turnId: id })
|
||||
return turnToTranscript(id, turn.events)
|
||||
} catch {
|
||||
const run = await window.ipc.invoke('runs:fetch', { runId: id })
|
||||
return runToTranscript(run)
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,26 @@ export function voiceInputStarted() {
|
|||
posthog.capture('voice_input_started')
|
||||
}
|
||||
|
||||
export function callStarted(preset: 'voice' | 'video' | 'share' | 'practice') {
|
||||
posthog.capture('call_started', { preset })
|
||||
}
|
||||
|
||||
// Voice-to-voice latency breakdown for one call turn (all milliseconds):
|
||||
// utterance accepted → message submitted → first TTS speak() → audio playing.
|
||||
export function callTurnLatency(props: {
|
||||
endpointToSubmitMs: number
|
||||
submitToSpeakMs: number
|
||||
speakToAudioMs: number
|
||||
totalMs: number
|
||||
}) {
|
||||
posthog.capture('call_turn_latency', {
|
||||
endpoint_to_submit_ms: Math.round(props.endpointToSubmitMs),
|
||||
submit_to_speak_ms: Math.round(props.submitToSpeakMs),
|
||||
speak_to_audio_ms: Math.round(props.speakToAudioMs),
|
||||
total_ms: Math.round(props.totalMs),
|
||||
})
|
||||
}
|
||||
|
||||
export function searchExecuted(types: string[]) {
|
||||
posthog.capture('search_executed', { types })
|
||||
}
|
||||
|
|
|
|||
30
apps/x/apps/renderer/src/lib/call-sounds.ts
Normal file
30
apps/x/apps/renderer/src/lib/call-sounds.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Tiny synthesized UI sounds for calls — no audio assets, one lazy context.
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
|
||||
/**
|
||||
* Soft rising blip played the instant an utterance is accepted — sub-second
|
||||
* acknowledgment makes the (still ongoing) model turn feel responsive
|
||||
* instead of dead air.
|
||||
*/
|
||||
export function playAckCue() {
|
||||
try {
|
||||
if (!ctx) ctx = new AudioContext()
|
||||
if (ctx.state === 'suspended') void ctx.resume()
|
||||
const t = ctx.currentTime
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(880, t)
|
||||
osc.frequency.exponentialRampToValueAtTime(1320, t + 0.08)
|
||||
gain.gain.setValueAtTime(0.0001, t)
|
||||
gain.gain.exponentialRampToValueAtTime(0.08, t + 0.015)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.12)
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + 0.13)
|
||||
} catch {
|
||||
// cosmetic — never let a sound failure affect the call
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ export interface MessageAttachment {
|
|||
mimeType: string
|
||||
size?: number
|
||||
thumbnailUrl?: string
|
||||
/** Live webcam frame from video chat mode — rendered as a compact filmstrip.
|
||||
* Carries no path; thumbnailUrl holds the frame as a data: URL. */
|
||||
isVideoFrame?: boolean
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
@ -118,6 +121,19 @@ export const normalizeToolOutput = (
|
|||
return output
|
||||
}
|
||||
|
||||
export const getToolErrorText = (tool: ToolCall): string | undefined => {
|
||||
if (tool.status !== 'error') return undefined
|
||||
if (typeof tool.result === 'string' && tool.result.trim()) return tool.result
|
||||
if (tool.result !== undefined) {
|
||||
try {
|
||||
return JSON.stringify(tool.result, null, 2)
|
||||
} catch {
|
||||
// Fall through to the generic label for non-serializable legacy values.
|
||||
}
|
||||
}
|
||||
return 'Tool error'
|
||||
}
|
||||
|
||||
export type WebSearchCardResult = { title: string; url: string; description: string }
|
||||
|
||||
export type WebSearchCardData = {
|
||||
|
|
@ -198,6 +214,22 @@ const summarizeFilterUpdates = (updates: Record<string, unknown>): string => {
|
|||
return parts.length > 0 ? parts.join(', ') : 'Updated view'
|
||||
}
|
||||
|
||||
const APP_VIEW_LABELS: Record<string, string> = {
|
||||
home: 'home',
|
||||
email: 'email',
|
||||
meetings: 'meetings',
|
||||
'live-notes': 'live notes',
|
||||
'bg-tasks': 'background agents',
|
||||
'chat-history': 'chat history',
|
||||
knowledge: 'knowledge',
|
||||
workspace: 'workspace',
|
||||
code: 'code',
|
||||
bases: 'bases',
|
||||
graph: 'graph',
|
||||
}
|
||||
|
||||
const appViewLabel = (view: unknown): string => APP_VIEW_LABELS[view as string] ?? String(view ?? 'view')
|
||||
|
||||
export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null => {
|
||||
if (tool.name !== 'app-navigation') return null
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
|
@ -209,7 +241,9 @@ export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null =
|
|||
const action = input.action as string
|
||||
switch (action) {
|
||||
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
|
||||
case 'open-view': return { action, label: `Opening ${input.view} view...` }
|
||||
case 'open-view': return { action, label: `Opening ${appViewLabel(input.view)}...` }
|
||||
case 'read-view': return { action, label: `Reading ${appViewLabel(input.view)}...` }
|
||||
case 'open-item': return { action, label: 'Opening...' }
|
||||
case 'update-base-view': return { action, label: 'Updating view...' }
|
||||
case 'create-base': return { action, label: `Creating "${input.name}"...` }
|
||||
case 'get-base-state': return null // renders as normal tool block
|
||||
|
|
@ -224,7 +258,31 @@ export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null =
|
|||
return { action: 'open-note', label: `Opened ${name}` }
|
||||
}
|
||||
case 'open-view':
|
||||
return { action: 'open-view', label: `Opened ${result.view} view` }
|
||||
return { action: 'open-view', label: `Opened ${appViewLabel(result.view)}` }
|
||||
case 'read-view': {
|
||||
const counted =
|
||||
(result.threads as unknown[] | undefined)?.length ??
|
||||
(result.agents as unknown[] | undefined)?.length ??
|
||||
(result.sessions as unknown[] | undefined)?.length
|
||||
return {
|
||||
action: 'read-view',
|
||||
label: counted !== undefined
|
||||
? `Read ${appViewLabel(result.view)} (${counted} item${counted === 1 ? '' : 's'})`
|
||||
: `Read ${appViewLabel(result.view)}`,
|
||||
}
|
||||
}
|
||||
case 'open-item': {
|
||||
switch (result.kind) {
|
||||
case 'email-thread': return { action: 'open-item', label: 'Opened email thread' }
|
||||
case 'note': {
|
||||
const name = (result.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'
|
||||
return { action: 'open-item', label: `Opened ${name}` }
|
||||
}
|
||||
case 'bg-task': return { action: 'open-item', label: `Opened agent "${result.taskName}"` }
|
||||
case 'session': return { action: 'open-item', label: 'Opened chat' }
|
||||
default: return { action: 'open-item', label: 'Opened item' }
|
||||
}
|
||||
}
|
||||
case 'update-base-view':
|
||||
return {
|
||||
action: 'update-base-view',
|
||||
|
|
|
|||
63
apps/x/apps/renderer/src/lib/code-mode-provisioning.ts
Normal file
63
apps/x/apps/renderer/src/lib/code-mode-provisioning.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
export type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
export type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
// Engine provisioning runs in the main process and keeps going even if the UI that
|
||||
// started it (the Settings dialog OR the onboarding step) unmounts. Track its state at
|
||||
// MODULE level — shared across both — so whichever view is mounted shows the live %
|
||||
// instead of restarting. This is what lets a download kicked off from onboarding show
|
||||
// its progress later in Settings → Code Mode. A single persistent listener on the
|
||||
// progress channel feeds this store.
|
||||
export type ProvState = { pct: number | null; error?: string }
|
||||
const provStore: Record<string, ProvState | undefined> = {}
|
||||
// Agents we provisioned this session — used to show "Ready" immediately on success
|
||||
// without waiting for the async status refresh to round-trip (which caused the row to
|
||||
// briefly flash the Enable button again).
|
||||
export const enabledOptimistic = new Set<string>()
|
||||
const provListeners = new Set<() => void>()
|
||||
let provChannelHooked = false
|
||||
|
||||
function notifyProv() { provListeners.forEach((l) => l()) }
|
||||
|
||||
export function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): void {
|
||||
if (provStore[agent] && !provStore[agent]!.error) return // already in flight
|
||||
provStore[agent] = { pct: null }
|
||||
notifyProv()
|
||||
if (!provChannelHooked) {
|
||||
provChannelHooked = true
|
||||
window.ipc.on('codeMode:engineProgress', (p) => {
|
||||
const cur = provStore[p.agent]
|
||||
if (!cur) return
|
||||
const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct
|
||||
provStore[p.agent] = { pct }
|
||||
notifyProv()
|
||||
})
|
||||
}
|
||||
window.ipc.invoke('codeMode:provisionEngine', { agent })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
// Mark installed optimistically so the row shows "Ready" the instant the flag
|
||||
// clears — don't depend on the async status refresh (which re-renders the parent
|
||||
// separately and left a window showing the Enable button). loadStatus still runs
|
||||
// in the background to sync the real status.
|
||||
enabledOptimistic.add(agent)
|
||||
provStore[agent] = undefined
|
||||
void onDone()
|
||||
} else {
|
||||
provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' }
|
||||
}
|
||||
})
|
||||
.catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } })
|
||||
.finally(notifyProv)
|
||||
}
|
||||
|
||||
export function useProvisioning(agent: string): ProvState | undefined {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
const l = () => force((n) => n + 1)
|
||||
provListeners.add(l)
|
||||
return () => { provListeners.delete(l) }
|
||||
}, [])
|
||||
return provStore[agent]
|
||||
}
|
||||
33
apps/x/apps/renderer/src/lib/deepgram-finalize.ts
Normal file
33
apps/x/apps/renderer/src/lib/deepgram-finalize.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export async function finalizeDeepgramStream(ws: WebSocket | null, timeoutMs = 1800): Promise<void> {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('message', onMessage);
|
||||
resolve();
|
||||
};
|
||||
timeout = setTimeout(finish, timeoutMs);
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.is_final || data?.speech_final || data?.type === 'UtteranceEnd') {
|
||||
finish();
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON control frames.
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'Finalize' }));
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@ export function runLogToConversation(log: RunLog): ConversationItem[] {
|
|||
filename?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
data?: string
|
||||
mediaType?: string
|
||||
source?: string
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
arguments?: unknown
|
||||
|
|
@ -52,13 +55,24 @@ export function runLogToConversation(log: RunLog): ConversationItem[] {
|
|||
.join('')
|
||||
|
||||
const attachmentParts = parts.filter((p) => p.type === 'attachment' && p.path)
|
||||
if (attachmentParts.length > 0) {
|
||||
msgAttachments = attachmentParts.map((p) => ({
|
||||
path: p.path!,
|
||||
filename: p.filename || p.path!.split('/').pop() || p.path!,
|
||||
mimeType: p.mimeType || 'application/octet-stream',
|
||||
size: p.size,
|
||||
}))
|
||||
// Video-mode webcam frames — inline base64 image parts, shown as a filmstrip
|
||||
const imageParts = parts.filter((p) => p.type === 'image' && p.data)
|
||||
if (attachmentParts.length > 0 || imageParts.length > 0) {
|
||||
msgAttachments = [
|
||||
...attachmentParts.map((p) => ({
|
||||
path: p.path!,
|
||||
filename: p.filename || p.path!.split('/').pop() || p.path!,
|
||||
mimeType: p.mimeType || 'application/octet-stream',
|
||||
size: p.size,
|
||||
})),
|
||||
...imageParts.map((p, index) => ({
|
||||
path: '',
|
||||
filename: `${p.source === 'screen' ? 'screen' : 'camera'}-frame-${index + 1}.jpg`,
|
||||
mimeType: p.mediaType || 'image/jpeg',
|
||||
thumbnailUrl: `data:${p.mediaType || 'image/jpeg'};base64,${p.data}`,
|
||||
isVideoFrame: true,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
|
|
|
|||
70
apps/x/apps/renderer/src/lib/session-chat/client.ts
Normal file
70
apps/x/apps/renderer/src/lib/session-chat/client.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { z } from 'zod'
|
||||
import type { UserMessage } from '@x/shared/src/message.js'
|
||||
import type { SessionIndexEntry, SessionState } from '@x/shared/src/sessions.js'
|
||||
import type { JsonValue, RequestedAgent, TurnEvent } from '@x/shared/src/turns.js'
|
||||
|
||||
// Narrow, injectable surface over the sessions IPC channels so stores are
|
||||
// testable with a plain fake instead of a window.ipc stub.
|
||||
export interface SendMessageConfig {
|
||||
agent: z.infer<typeof RequestedAgent>
|
||||
autoPermission?: boolean
|
||||
maxModelCalls?: number
|
||||
}
|
||||
|
||||
export interface SessionsClient {
|
||||
create(input: { title?: string }): Promise<{ sessionId: string }>
|
||||
list(): Promise<{ sessions: SessionIndexEntry[] }>
|
||||
get(sessionId: string): Promise<SessionState>
|
||||
getTurn(turnId: string): Promise<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
input: z.infer<typeof UserMessage>,
|
||||
config: SendMessageConfig,
|
||||
): Promise<{ turnId: string }>
|
||||
respondToPermission(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
decision: 'allow' | 'deny',
|
||||
metadata?: JsonValue,
|
||||
): Promise<void>
|
||||
respondToAskHuman(turnId: string, toolCallId: string, answer: string): Promise<void>
|
||||
stopTurn(turnId: string, reason?: string): Promise<void>
|
||||
resumeTurn(sessionId: string): Promise<void>
|
||||
setTitle(sessionId: string, title: string): Promise<void>
|
||||
delete(sessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
export const ipcSessionsClient: SessionsClient = {
|
||||
create: (input) => window.ipc.invoke('sessions:create', input),
|
||||
list: () => window.ipc.invoke('sessions:list', {}),
|
||||
get: (sessionId) => window.ipc.invoke('sessions:get', { sessionId }),
|
||||
getTurn: (turnId) => window.ipc.invoke('sessions:getTurn', { turnId }),
|
||||
sendMessage: (sessionId, input, config) =>
|
||||
window.ipc.invoke('sessions:sendMessage', { sessionId, input, config }),
|
||||
respondToPermission: async (turnId, toolCallId, decision, metadata) => {
|
||||
await window.ipc.invoke('sessions:respondToPermission', {
|
||||
turnId,
|
||||
toolCallId,
|
||||
decision,
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
})
|
||||
},
|
||||
respondToAskHuman: async (turnId, toolCallId, answer) => {
|
||||
await window.ipc.invoke('sessions:respondToAskHuman', { turnId, toolCallId, answer })
|
||||
},
|
||||
stopTurn: async (turnId, reason) => {
|
||||
await window.ipc.invoke('sessions:stopTurn', {
|
||||
turnId,
|
||||
...(reason === undefined ? {} : { reason }),
|
||||
})
|
||||
},
|
||||
resumeTurn: async (sessionId) => {
|
||||
await window.ipc.invoke('sessions:resumeTurn', { sessionId })
|
||||
},
|
||||
setTitle: async (sessionId, title) => {
|
||||
await window.ipc.invoke('sessions:setTitle', { sessionId, title })
|
||||
},
|
||||
delete: async (sessionId) => {
|
||||
await window.ipc.invoke('sessions:delete', { sessionId })
|
||||
},
|
||||
}
|
||||
52
apps/x/apps/renderer/src/lib/session-chat/feed.test.ts
Normal file
52
apps/x/apps/renderer/src/lib/session-chat/feed.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
import { createSessionFeed, type SessionFeedListener } from './feed'
|
||||
|
||||
const event: SessionBusEvent = {
|
||||
kind: 'index-changed',
|
||||
sessionId: 's1',
|
||||
entry: null,
|
||||
}
|
||||
|
||||
describe('createSessionFeed', () => {
|
||||
it('starts the source lazily on first subscribe and fans events out', () => {
|
||||
let sourceStarted = 0
|
||||
let push: SessionFeedListener = () => undefined
|
||||
const feed = createSessionFeed((listener) => {
|
||||
sourceStarted += 1
|
||||
push = listener
|
||||
return () => undefined
|
||||
})
|
||||
expect(sourceStarted).toBe(0)
|
||||
|
||||
const seenA: SessionBusEvent[] = []
|
||||
const seenB: SessionBusEvent[] = []
|
||||
feed.subscribe((e) => seenA.push(e))
|
||||
feed.subscribe((e) => seenB.push(e))
|
||||
expect(sourceStarted).toBe(1) // one shared IPC listener
|
||||
|
||||
push(event)
|
||||
expect(seenA).toEqual([event])
|
||||
expect(seenB).toEqual([event])
|
||||
})
|
||||
|
||||
it('unsubscribes cleanly and isolates a throwing listener', () => {
|
||||
let push: SessionFeedListener = () => undefined
|
||||
const feed = createSessionFeed((listener) => {
|
||||
push = listener
|
||||
return () => undefined
|
||||
})
|
||||
const seen: SessionBusEvent[] = []
|
||||
feed.subscribe(() => {
|
||||
throw new Error('bad subscriber')
|
||||
})
|
||||
const unsubscribe = feed.subscribe((e) => seen.push(e))
|
||||
|
||||
push(event)
|
||||
expect(seen).toEqual([event])
|
||||
|
||||
unsubscribe()
|
||||
push(event)
|
||||
expect(seen).toEqual([event])
|
||||
})
|
||||
})
|
||||
42
apps/x/apps/renderer/src/lib/session-chat/feed.ts
Normal file
42
apps/x/apps/renderer/src/lib/session-chat/feed.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
|
||||
export type SessionFeedListener = (event: SessionBusEvent) => void
|
||||
export type SessionFeedSource = (listener: SessionFeedListener) => () => void
|
||||
|
||||
// One shared consumer of the sessions:events push channel; stores tap this
|
||||
// fan-out instead of each opening their own IPC listener. Factory so tests
|
||||
// can drive a fake source.
|
||||
export function createSessionFeed(source: SessionFeedSource) {
|
||||
const listeners = new Set<SessionFeedListener>()
|
||||
let detach: (() => void) | null = null
|
||||
|
||||
const ensureStarted = () => {
|
||||
if (detach) return
|
||||
detach = source((event) => {
|
||||
// Copy so (un)subscribing during dispatch is safe.
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch {
|
||||
// A misbehaving subscriber must never break the feed.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(listener: SessionFeedListener): () => void {
|
||||
ensureStarted()
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const appFeed = createSessionFeed((listener) => window.ipc.on('sessions:events', listener))
|
||||
|
||||
export function subscribeSessionFeed(listener: SessionFeedListener): () => void {
|
||||
return appFeed.subscribe(listener)
|
||||
}
|
||||
314
apps/x/apps/renderer/src/lib/session-chat/store.test.ts
Normal file
314
apps/x/apps/renderer/src/lib/session-chat/store.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent, SessionState } from '@x/shared/src/sessions.js'
|
||||
import { isChatMessage } from '@/lib/chat-conversation'
|
||||
import type { SessionsClient } from './client'
|
||||
import type { SessionFeedListener } from './feed'
|
||||
import { SessionChatStore, SessionListStore } from './store'
|
||||
import {
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
indexEntry,
|
||||
requested,
|
||||
sessionState,
|
||||
turnCompleted,
|
||||
user,
|
||||
type TEvent,
|
||||
} from './test-fixtures'
|
||||
|
||||
const S1 = 'sess-1'
|
||||
|
||||
class FakeClient implements SessionsClient {
|
||||
sessions = new Map<string, SessionState>()
|
||||
turns = new Map<string, TEvent[]>()
|
||||
calls: Array<{ method: string; args: unknown[] }> = []
|
||||
// When set, get() defers until the returned resolver is invoked.
|
||||
deferredGet: (() => void) | null = null
|
||||
|
||||
private record(method: string, ...args: unknown[]) {
|
||||
this.calls.push({ method, args })
|
||||
}
|
||||
|
||||
async create(input: { title?: string }) {
|
||||
this.record('create', input)
|
||||
return { sessionId: 'new-session' }
|
||||
}
|
||||
async list() {
|
||||
this.record('list')
|
||||
return { sessions: [...this.sessions.keys()].map((id) => indexEntry(id)) }
|
||||
}
|
||||
async get(sessionId: string) {
|
||||
this.record('get', sessionId)
|
||||
if (this.deferredGet === null) {
|
||||
const state = this.sessions.get(sessionId)
|
||||
if (!state) throw new Error(`session not found: ${sessionId}`)
|
||||
return state
|
||||
}
|
||||
return new Promise<SessionState>((resolve, reject) => {
|
||||
this.deferredGet = () => {
|
||||
const state = this.sessions.get(sessionId)
|
||||
if (state) resolve(state)
|
||||
else reject(new Error(`session not found: ${sessionId}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
async getTurn(turnId: string) {
|
||||
this.record('getTurn', turnId)
|
||||
const events = this.turns.get(turnId)
|
||||
if (!events) throw new Error(`turn not found: ${turnId}`)
|
||||
return { turnId, events }
|
||||
}
|
||||
async sendMessage(sessionId: string, input: unknown, config: unknown) {
|
||||
this.record('sendMessage', sessionId, input, config)
|
||||
return { turnId: 'turn-next' }
|
||||
}
|
||||
async respondToPermission(...args: unknown[]) {
|
||||
this.record('respondToPermission', ...args)
|
||||
}
|
||||
async respondToAskHuman(...args: unknown[]) {
|
||||
this.record('respondToAskHuman', ...args)
|
||||
}
|
||||
async stopTurn(...args: unknown[]) {
|
||||
this.record('stopTurn', ...args)
|
||||
}
|
||||
async resumeTurn(...args: unknown[]) {
|
||||
this.record('resumeTurn', ...args)
|
||||
}
|
||||
async setTitle(...args: unknown[]) {
|
||||
this.record('setTitle', ...args)
|
||||
}
|
||||
async delete(...args: unknown[]) {
|
||||
this.record('delete', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
const client = new FakeClient()
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
let subscribed = 0
|
||||
let unsubscribed = 0
|
||||
const store = new SessionChatStore({
|
||||
client,
|
||||
subscribeFeed: (listener) => {
|
||||
subscribed += 1
|
||||
emit = listener
|
||||
return () => {
|
||||
unsubscribed += 1
|
||||
emit = () => undefined
|
||||
}
|
||||
},
|
||||
})
|
||||
const disconnect = store.connect()
|
||||
return {
|
||||
client,
|
||||
store,
|
||||
disconnect,
|
||||
emit: (event: SessionBusEvent) => emit(event),
|
||||
getSubscribed: () => subscribed,
|
||||
getUnsubscribed: () => unsubscribed,
|
||||
}
|
||||
}
|
||||
|
||||
function turnEvent(
|
||||
sessionId: string,
|
||||
turnId: string,
|
||||
event:
|
||||
| TEvent
|
||||
| { type: 'text_delta' | 'reasoning_delta'; turnId: string; modelCallIndex: number; delta: string },
|
||||
): SessionBusEvent {
|
||||
return { kind: 'turn-event', sessionId, turnId, event }
|
||||
}
|
||||
|
||||
describe('SessionChatStore', () => {
|
||||
it('seeds a session: prior turns frozen, latest live, conversation composed', async () => {
|
||||
const { client, store } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, ['turn-1', 'turn-2']))
|
||||
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'first?', 'first answer'))
|
||||
client.turns.set('turn-2', completedTurnLog('turn-2', S1, 'second?', 'second answer'))
|
||||
|
||||
await store.setSession(S1)
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.loading).toBe(false)
|
||||
expect(snapshot.latestTurnId).toBe('turn-2')
|
||||
expect(
|
||||
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['first?', 'first answer', 'second?', 'second answer'])
|
||||
expect(snapshot.chatState?.isProcessing).toBe(false)
|
||||
})
|
||||
|
||||
it('applies live durable events through the shared reducer', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
expect(store.getSnapshot().chatState?.isThinking).toBe(true)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.latestTurnId).toBe('turn-1')
|
||||
expect(snapshot.chatState?.isProcessing).toBe(false)
|
||||
expect(
|
||||
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['go', 'done'])
|
||||
})
|
||||
|
||||
it('accumulates text deltas and clears them on the canonical response', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'he' }))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'y' }))
|
||||
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('hey')
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('hey'))))
|
||||
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('')
|
||||
})
|
||||
|
||||
it('freezes the previous latest turn when a new turn starts', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, ['turn-1']))
|
||||
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1'))
|
||||
await store.setSession(S1)
|
||||
|
||||
emit(turnEvent(S1, 'turn-2', created('turn-2', S1, user('q2'))))
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.latestTurnId).toBe('turn-2')
|
||||
expect(
|
||||
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['q1', 'a1', 'q2'])
|
||||
})
|
||||
|
||||
it('ignores events for other sessions', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent('other-session', 'turn-x', created('turn-x', 'other-session')))
|
||||
expect(store.getSnapshot().chatState?.conversation).toEqual([])
|
||||
})
|
||||
|
||||
it('reconciles an unknown mid-turn event by refetching the turn', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
// The feed attached mid-turn: we never saw turn_created for turn-9.
|
||||
client.turns.set('turn-9', [
|
||||
created('turn-9', S1, user('missed')),
|
||||
requested('turn-9', 0),
|
||||
])
|
||||
emit(turnEvent(S1, 'turn-9', completed('turn-9', 0, assistantText('caught up'))))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(store.getSnapshot().latestTurnId).toBe('turn-9')
|
||||
})
|
||||
|
||||
it('routes actions against the latest turn', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1)))
|
||||
|
||||
await store.sendMessage(user('next'), { agent: { agentId: 'copilot' } })
|
||||
await store.respondToPermission('tc1', 'allow')
|
||||
await store.answerAskHuman('ah1', '42')
|
||||
await store.stop()
|
||||
|
||||
expect(client.calls.filter((c) => c.method !== 'get' && c.method !== 'getTurn')).toEqual([
|
||||
{ method: 'sendMessage', args: [S1, user('next'), { agent: { agentId: 'copilot' } }] },
|
||||
{ method: 'respondToPermission', args: ['turn-1', 'tc1', 'allow', undefined] },
|
||||
{ method: 'respondToAskHuman', args: ['turn-1', 'ah1', '42'] },
|
||||
{ method: 'stopTurn', args: ['turn-1'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects sendMessage without an active session', async () => {
|
||||
const { store } = makeStore()
|
||||
await expect(
|
||||
store.sendMessage(user('x'), { agent: { agentId: 'copilot' } }),
|
||||
).rejects.toThrowError('No active session')
|
||||
})
|
||||
|
||||
it('drops stale loads after a session switch', async () => {
|
||||
const { client, store } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, ['turn-1']))
|
||||
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'old', 'old answer'))
|
||||
client.sessions.set('sess-2', sessionState('sess-2', []))
|
||||
|
||||
client.deferredGet = () => undefined // arm deferral
|
||||
const first = store.setSession(S1)
|
||||
const release = client.deferredGet
|
||||
client.deferredGet = null
|
||||
const second = store.setSession('sess-2')
|
||||
release?.() // S1's get resolves after the switch
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(store.getSnapshot().sessionId).toBe('sess-2')
|
||||
expect(store.getSnapshot().chatState?.conversation).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces load errors and disconnects its feed subscription', async () => {
|
||||
const { store, disconnect, getUnsubscribed } = makeStore()
|
||||
await store.setSession('missing-session')
|
||||
expect(store.getSnapshot().error).toMatch(/session not found/)
|
||||
disconnect()
|
||||
expect(getUnsubscribed()).toBe(1)
|
||||
void store
|
||||
})
|
||||
|
||||
it('survives a StrictMode-style connect -> cleanup -> connect cycle', async () => {
|
||||
// React StrictMode runs every effect's mount/cleanup/mount cycle in dev;
|
||||
// the feed must re-attach or the store goes permanently deaf (the bug
|
||||
// this test pins: a constructor-made subscription torn down by the first
|
||||
// cleanup was never restored, leaving the chat stuck at "Thinking…").
|
||||
const { client, store, disconnect, emit, getSubscribed } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
disconnect()
|
||||
const disconnect2 = store.connect()
|
||||
expect(getSubscribed()).toBe(2)
|
||||
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
|
||||
expect(store.getSnapshot().chatState?.isProcessing).toBe(false)
|
||||
expect(
|
||||
store.getSnapshot().chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['go', 'done'])
|
||||
disconnect2()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionListStore', () => {
|
||||
it('loads, applies index updates and deletions, and sorts by updatedAt', async () => {
|
||||
const client = new FakeClient()
|
||||
client.sessions.set('a', sessionState('a', []))
|
||||
client.sessions.set('b', sessionState('b', []))
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
const store = new SessionListStore({
|
||||
client,
|
||||
subscribeFeed: (listener) => {
|
||||
emit = listener
|
||||
return () => undefined
|
||||
},
|
||||
})
|
||||
store.connect()
|
||||
await store.load()
|
||||
expect(store.getSnapshot().sessions.map((s) => s.sessionId).sort()).toEqual(['a', 'b'])
|
||||
|
||||
emit({
|
||||
kind: 'index-changed',
|
||||
sessionId: 'c',
|
||||
entry: indexEntry('c', { updatedAt: '2026-07-03T00:00:00Z' }),
|
||||
})
|
||||
expect(store.getSnapshot().sessions[0].sessionId).toBe('c')
|
||||
|
||||
emit({ kind: 'index-changed', sessionId: 'a', entry: null })
|
||||
expect(store.getSnapshot().sessions.map((s) => s.sessionId)).toEqual(['c', 'b'])
|
||||
})
|
||||
})
|
||||
314
apps/x/apps/renderer/src/lib/session-chat/store.ts
Normal file
314
apps/x/apps/renderer/src/lib/session-chat/store.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import type { z } from 'zod'
|
||||
import type { UserMessage } from '@x/shared/src/message.js'
|
||||
import type { SessionBusEvent, SessionIndexEntry } from '@x/shared/src/sessions.js'
|
||||
import {
|
||||
reduceTurn,
|
||||
type JsonValue,
|
||||
type TurnEvent,
|
||||
type TurnState,
|
||||
type TurnStreamEvent,
|
||||
} from '@x/shared/src/turns.js'
|
||||
import type { SendMessageConfig, SessionsClient } from './client'
|
||||
import type { SessionFeedListener } from './feed'
|
||||
import {
|
||||
applyOverlay,
|
||||
buildSessionChatState,
|
||||
emptyOverlay,
|
||||
type LiveOverlay,
|
||||
type SessionChatState,
|
||||
} from './turn-view'
|
||||
|
||||
type TEvent = z.infer<typeof TurnEvent>
|
||||
|
||||
export interface SessionChatSnapshot {
|
||||
sessionId: string | null
|
||||
chatState: SessionChatState | null
|
||||
latestTurnId: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SessionChatStoreDeps {
|
||||
client: SessionsClient
|
||||
subscribeFeed: (listener: SessionFeedListener) => () => void
|
||||
}
|
||||
|
||||
// Framework-agnostic controller for one active session's chat. Owns all the
|
||||
// logic (seeding via getSession/getTurn, applying live feed events with the
|
||||
// shared reducer, the ephemeral overlay, action routing); the useSessionChat
|
||||
// hook is a thin useSyncExternalStore subscription over it.
|
||||
export class SessionChatStore {
|
||||
private readonly client: SessionsClient
|
||||
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
|
||||
private feedDisconnect: (() => void) | null = null
|
||||
private readonly listeners = new Set<() => void>()
|
||||
|
||||
private sessionId: string | null = null
|
||||
// Settled earlier turns, reduced once and frozen.
|
||||
private priorTurns: TurnState[] = []
|
||||
// The latest turn's raw event log; re-reduced on each durable event.
|
||||
private latestEvents: TEvent[] | null = null
|
||||
private overlay: LiveOverlay = emptyOverlay()
|
||||
private loading = false
|
||||
private error: string | null = null
|
||||
// Guards stale async loads after a session switch.
|
||||
private generation = 0
|
||||
|
||||
private snapshot: SessionChatSnapshot = {
|
||||
sessionId: null,
|
||||
chatState: null,
|
||||
latestTurnId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
constructor(deps: SessionChatStoreDeps) {
|
||||
this.client = deps.client
|
||||
this.subscribeFeed = deps.subscribeFeed
|
||||
}
|
||||
|
||||
// Feed attachment is effect-managed and idempotent so React StrictMode's
|
||||
// mount -> cleanup -> mount cycle re-attaches cleanly (a constructor-made
|
||||
// subscription would be torn down by the first cleanup and never restored).
|
||||
connect(): () => void {
|
||||
if (!this.feedDisconnect) {
|
||||
this.feedDisconnect = this.subscribeFeed(this.onFeedEvent)
|
||||
}
|
||||
return () => {
|
||||
this.feedDisconnect?.()
|
||||
this.feedDisconnect = null
|
||||
}
|
||||
}
|
||||
|
||||
subscribe = (onChange: () => void): (() => void) => {
|
||||
this.listeners.add(onChange)
|
||||
return () => {
|
||||
this.listeners.delete(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot = (): SessionChatSnapshot => this.snapshot
|
||||
|
||||
async setSession(sessionId: string | null): Promise<void> {
|
||||
if (sessionId === this.sessionId) return
|
||||
this.generation += 1
|
||||
const generation = this.generation
|
||||
this.sessionId = sessionId
|
||||
this.priorTurns = []
|
||||
this.latestEvents = null
|
||||
this.overlay = emptyOverlay()
|
||||
this.error = null
|
||||
this.loading = sessionId !== null
|
||||
this.emit()
|
||||
if (sessionId === null) return
|
||||
|
||||
try {
|
||||
const state = await this.client.get(sessionId)
|
||||
const turns = await Promise.all(
|
||||
state.turns.map((ref) => this.client.getTurn(ref.turnId)),
|
||||
)
|
||||
if (generation !== this.generation) return
|
||||
const reduced = turns.map((turn) => reduceTurn(turn.events))
|
||||
this.priorTurns = reduced.slice(0, -1)
|
||||
this.latestEvents = turns.length > 0 ? turns[turns.length - 1].events : null
|
||||
this.loading = false
|
||||
this.emit()
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
console.error('[session-chat] failed to load session', sessionId, error)
|
||||
this.loading = false
|
||||
this.error = error instanceof Error ? error.message : String(error)
|
||||
this.emit()
|
||||
}
|
||||
}
|
||||
|
||||
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
|
||||
if (event.kind !== 'turn-event' || event.sessionId !== this.sessionId) return
|
||||
const turnEvent = event.event
|
||||
if (isDurable(turnEvent)) {
|
||||
if (turnEvent.type === 'turn_created') {
|
||||
// A new turn started for this session: freeze the previous latest.
|
||||
this.freezeLatest()
|
||||
this.latestEvents = [turnEvent]
|
||||
this.overlay = emptyOverlay()
|
||||
} else if (this.latestEvents && this.latestEvents[0].turnId === turnEvent.turnId) {
|
||||
this.latestEvents.push(turnEvent)
|
||||
} else {
|
||||
// An event for a turn we haven't seen (missed turn_created, e.g. the
|
||||
// feed attached mid-turn): reconcile by refetching that turn.
|
||||
void this.reloadTurn(event.turnId)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.overlay = applyOverlay(this.overlay, turnEvent)
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private freezeLatest(): void {
|
||||
if (!this.latestEvents) return
|
||||
try {
|
||||
this.priorTurns = [...this.priorTurns, reduceTurn(this.latestEvents)]
|
||||
} catch {
|
||||
// A turn we can't reduce is dropped from history rather than wedging
|
||||
// the whole conversation.
|
||||
}
|
||||
this.latestEvents = null
|
||||
}
|
||||
|
||||
private async reloadTurn(turnId: string): Promise<void> {
|
||||
const generation = this.generation
|
||||
try {
|
||||
const turn = await this.client.getTurn(turnId)
|
||||
if (generation !== this.generation) return
|
||||
if (this.latestEvents && this.latestEvents[0].turnId !== turnId) {
|
||||
this.freezeLatest()
|
||||
}
|
||||
this.latestEvents = turn.events
|
||||
this.emit()
|
||||
} catch (error) {
|
||||
// The next snapshot-worthy event will retry.
|
||||
console.error('[session-chat] failed to reload turn', turnId, error)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
sendMessage = async (
|
||||
input: z.infer<typeof UserMessage>,
|
||||
config: SendMessageConfig,
|
||||
): Promise<{ turnId: string }> => {
|
||||
if (!this.sessionId) throw new Error('No active session')
|
||||
return this.client.sendMessage(this.sessionId, input, config)
|
||||
}
|
||||
|
||||
respondToPermission = async (
|
||||
toolCallId: string,
|
||||
decision: 'allow' | 'deny',
|
||||
metadata?: JsonValue,
|
||||
): Promise<void> => {
|
||||
const turnId = this.snapshot.latestTurnId
|
||||
if (!turnId) return
|
||||
await this.client.respondToPermission(turnId, toolCallId, decision, metadata)
|
||||
}
|
||||
|
||||
answerAskHuman = async (toolCallId: string, answer: string): Promise<void> => {
|
||||
const turnId = this.snapshot.latestTurnId
|
||||
if (!turnId) return
|
||||
await this.client.respondToAskHuman(turnId, toolCallId, answer)
|
||||
}
|
||||
|
||||
stop = async (): Promise<void> => {
|
||||
const turnId = this.snapshot.latestTurnId
|
||||
if (!turnId) return
|
||||
await this.client.stopTurn(turnId)
|
||||
}
|
||||
|
||||
// ── Derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
private emit(): void {
|
||||
this.snapshot = this.derive()
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
private derive(): SessionChatSnapshot {
|
||||
let turns = this.priorTurns
|
||||
let error = this.error
|
||||
if (this.latestEvents) {
|
||||
try {
|
||||
turns = [...this.priorTurns, reduceTurn(this.latestEvents)]
|
||||
} catch (reduceError) {
|
||||
error =
|
||||
reduceError instanceof Error ? reduceError.message : String(reduceError)
|
||||
}
|
||||
}
|
||||
const latest = turns[turns.length - 1]
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
chatState:
|
||||
this.sessionId !== null && !this.loading
|
||||
? buildSessionChatState(turns, this.overlay)
|
||||
: null,
|
||||
latestTurnId: latest?.definition.turnId ?? null,
|
||||
loading: this.loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDurable(event: TurnStreamEvent): event is TEvent {
|
||||
return event.type !== 'text_delta' && event.type !== 'reasoning_delta'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session list store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionListSnapshot {
|
||||
sessions: SessionIndexEntry[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export class SessionListStore {
|
||||
private readonly client: SessionsClient
|
||||
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
|
||||
private feedDisconnect: (() => void) | null = null
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private entries = new Map<string, SessionIndexEntry>()
|
||||
private loading = true
|
||||
private snapshot: SessionListSnapshot = { sessions: [], loading: true }
|
||||
|
||||
constructor(deps: SessionChatStoreDeps) {
|
||||
this.client = deps.client
|
||||
this.subscribeFeed = deps.subscribeFeed
|
||||
}
|
||||
|
||||
connect(): () => void {
|
||||
if (!this.feedDisconnect) {
|
||||
this.feedDisconnect = this.subscribeFeed(this.onFeedEvent)
|
||||
}
|
||||
return () => {
|
||||
this.feedDisconnect?.()
|
||||
this.feedDisconnect = null
|
||||
}
|
||||
}
|
||||
|
||||
subscribe = (onChange: () => void): (() => void) => {
|
||||
this.listeners.add(onChange)
|
||||
return () => {
|
||||
this.listeners.delete(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot = (): SessionListSnapshot => this.snapshot
|
||||
|
||||
async load(): Promise<void> {
|
||||
const { sessions } = await this.client.list()
|
||||
this.entries = new Map(sessions.map((entry) => [entry.sessionId, entry]))
|
||||
this.loading = false
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
|
||||
if (event.kind !== 'index-changed') return
|
||||
if (event.entry === null) {
|
||||
this.entries.delete(event.sessionId)
|
||||
} else {
|
||||
this.entries.set(event.sessionId, event.entry)
|
||||
}
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.snapshot = {
|
||||
sessions: [...this.entries.values()].sort((a, b) =>
|
||||
b.updatedAt.localeCompare(a.updatedAt),
|
||||
),
|
||||
loading: this.loading,
|
||||
}
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
171
apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts
Normal file
171
apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import type { z } from 'zod'
|
||||
import type { SessionIndexEntry, SessionState } from '@x/shared/src/sessions.js'
|
||||
import type { ResolvedAgent, TurnEvent } from '@x/shared/src/turns.js'
|
||||
|
||||
// Compact turn-event builders for renderer tests. Sequences must satisfy the
|
||||
// shared reducer's invariants (reduceTurn is what the store runs on them).
|
||||
|
||||
export type TEvent = z.infer<typeof TurnEvent>
|
||||
|
||||
export const TS = '2026-07-02T10:00:00Z'
|
||||
|
||||
export const FIXTURE_AGENT: z.infer<typeof ResolvedAgent> = {
|
||||
agentId: 'copilot',
|
||||
systemPrompt: 'SYS',
|
||||
model: { provider: 'openai', model: 'gpt-fixture' },
|
||||
tools: [],
|
||||
}
|
||||
|
||||
export function user(text: string) {
|
||||
return { role: 'user' as const, content: text }
|
||||
}
|
||||
|
||||
export function assistantText(text: string) {
|
||||
return { role: 'assistant' as const, content: text }
|
||||
}
|
||||
|
||||
export function toolCallPart(id: string, name: string, args: unknown = {}) {
|
||||
return { type: 'tool-call' as const, toolCallId: id, toolName: name, arguments: args }
|
||||
}
|
||||
|
||||
export function created(
|
||||
turnId: string,
|
||||
sessionId: string,
|
||||
input: ReturnType<typeof user> = user('hello'),
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'turn_created',
|
||||
schemaVersion: 1,
|
||||
turnId,
|
||||
ts: TS,
|
||||
sessionId,
|
||||
agent: { requested: { agentId: 'copilot' }, resolved: FIXTURE_AGENT },
|
||||
context: [],
|
||||
input,
|
||||
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 20 },
|
||||
}
|
||||
}
|
||||
|
||||
export function requested(
|
||||
turnId: string,
|
||||
index: number,
|
||||
refs: string[] = ['input'],
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'model_call_requested',
|
||||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
request: {
|
||||
messages: refs,
|
||||
parameters: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function completed(
|
||||
turnId: string,
|
||||
index: number,
|
||||
message: { role: 'assistant'; content: unknown },
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'model_call_completed',
|
||||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
message: message as never,
|
||||
finishReason: 'stop',
|
||||
usage: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function invocation(turnId: string, toolCallId: string, name: string): TEvent {
|
||||
return {
|
||||
type: 'tool_invocation_requested',
|
||||
turnId,
|
||||
ts: TS,
|
||||
toolCallId,
|
||||
toolId: `builtin:${name}`,
|
||||
toolName: name,
|
||||
execution: 'sync',
|
||||
input: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function toolResult(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
name: string,
|
||||
output: unknown = 'ok',
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'tool_result',
|
||||
turnId,
|
||||
ts: TS,
|
||||
toolCallId,
|
||||
toolName: name,
|
||||
source: 'sync',
|
||||
result: { output: output as never, isError: false },
|
||||
}
|
||||
}
|
||||
|
||||
export function turnCompleted(turnId: string, text = 'done'): TEvent {
|
||||
return {
|
||||
type: 'turn_completed',
|
||||
turnId,
|
||||
ts: TS,
|
||||
output: assistantText(text),
|
||||
finishReason: 'stop',
|
||||
usage: {},
|
||||
}
|
||||
}
|
||||
|
||||
// A settled single-response turn.
|
||||
export function completedTurnLog(
|
||||
turnId: string,
|
||||
sessionId: string,
|
||||
question: string,
|
||||
answer: string,
|
||||
): TEvent[] {
|
||||
return [
|
||||
created(turnId, sessionId, user(question)),
|
||||
requested(turnId, 0),
|
||||
completed(turnId, 0, assistantText(answer)),
|
||||
turnCompleted(turnId, answer),
|
||||
]
|
||||
}
|
||||
|
||||
export function indexEntry(
|
||||
sessionId: string,
|
||||
overrides: Partial<SessionIndexEntry> = {},
|
||||
): SessionIndexEntry {
|
||||
return {
|
||||
sessionId,
|
||||
title: `Session ${sessionId}`,
|
||||
createdAt: TS,
|
||||
updatedAt: TS,
|
||||
turnCount: 1,
|
||||
latestTurnStatus: 'completed',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionState(
|
||||
sessionId: string,
|
||||
turnIds: string[],
|
||||
): SessionState {
|
||||
return {
|
||||
definition: { type: 'session_created', schemaVersion: 1, sessionId, ts: TS },
|
||||
title: `Session ${sessionId}`,
|
||||
turns: turnIds.map((turnId, i) => ({
|
||||
turnId,
|
||||
sessionSeq: i + 1,
|
||||
agentId: 'copilot',
|
||||
model: FIXTURE_AGENT.model,
|
||||
ts: TS,
|
||||
})),
|
||||
latestTurnId: turnIds[turnIds.length - 1],
|
||||
createdAt: TS,
|
||||
updatedAt: TS,
|
||||
}
|
||||
}
|
||||
362
apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts
Normal file
362
apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { reduceTurn } from '@x/shared/src/turns.js'
|
||||
import { isChatMessage, isErrorMessage, isToolCall } from '@/lib/chat-conversation'
|
||||
import {
|
||||
applyOverlay,
|
||||
buildSessionChatState,
|
||||
buildTurnConversation,
|
||||
emptyOverlay,
|
||||
} from './turn-view'
|
||||
import {
|
||||
TS,
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
invocation,
|
||||
requested,
|
||||
toolCallPart,
|
||||
toolResult,
|
||||
turnCompleted,
|
||||
user,
|
||||
type TEvent,
|
||||
} from './test-fixtures'
|
||||
|
||||
const T1 = 'turn-1'
|
||||
const S1 = 'sess-1'
|
||||
|
||||
function assistantCalls(...parts: Array<ReturnType<typeof toolCallPart>>) {
|
||||
return { role: 'assistant' as const, content: parts }
|
||||
}
|
||||
|
||||
describe('applyOverlay', () => {
|
||||
it('accumulates text and reasoning deltas', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, { type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: 'he' })
|
||||
overlay = applyOverlay(overlay, { type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: 'y' })
|
||||
overlay = applyOverlay(overlay, {
|
||||
type: 'reasoning_delta',
|
||||
turnId: T1,
|
||||
modelCallIndex: 0,
|
||||
delta: 'hmm',
|
||||
})
|
||||
expect(overlay.text).toBe('hey')
|
||||
expect(overlay.reasoning).toBe('hmm')
|
||||
})
|
||||
|
||||
it('clears text/reasoning when the canonical model response arrives', () => {
|
||||
let overlay = { ...emptyOverlay(), text: 'streaming', reasoning: 'thinking' }
|
||||
overlay = applyOverlay(overlay, completed(T1, 0, assistantText('final')))
|
||||
expect(overlay.text).toBe('')
|
||||
expect(overlay.reasoning).toBe('')
|
||||
})
|
||||
|
||||
it('accumulates tool-output progress chunks and drops them on the terminal result', () => {
|
||||
let overlay = emptyOverlay()
|
||||
const progress = (chunk: string): TEvent => ({
|
||||
type: 'tool_progress',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'tc1',
|
||||
source: 'sync',
|
||||
progress: { kind: 'tool-output', chunk },
|
||||
})
|
||||
overlay = applyOverlay(overlay, progress('$ ls\n'))
|
||||
overlay = applyOverlay(overlay, progress('README.md\n'))
|
||||
expect(overlay.toolOutput.tc1).toBe('$ ls\nREADME.md\n')
|
||||
|
||||
overlay = applyOverlay(overlay, toolResult(T1, 'tc1', 'executeCommand'))
|
||||
expect(overlay.toolOutput.tc1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores non-output progress payloads', () => {
|
||||
const overlay = applyOverlay(emptyOverlay(), {
|
||||
type: 'tool_progress',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'tc1',
|
||||
source: 'sync',
|
||||
progress: { pct: 50 },
|
||||
})
|
||||
expect(overlay.toolOutput).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('voice output', () => {
|
||||
const delta = (d: string): Parameters<typeof applyOverlay>[1] =>
|
||||
({ type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: d })
|
||||
|
||||
it('extracts completed <voice> blocks across split deltas', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('Sure! <voi'))
|
||||
expect(overlay.voiceSegments).toEqual([])
|
||||
overlay = applyOverlay(overlay, delta('ce>hello there</voice> and <voice>bye'))
|
||||
expect(overlay.voiceSegments).toEqual(['hello there'])
|
||||
overlay = applyOverlay(overlay, delta('</voice> done'))
|
||||
expect(overlay.voiceSegments).toEqual(['hello there', 'bye'])
|
||||
})
|
||||
|
||||
it('emits an early clause from a long open block, then the remainder on close', () => {
|
||||
let overlay = emptyOverlay()
|
||||
const longClause = 'Okay so the first thing I would look at here is the error message,'
|
||||
overlay = applyOverlay(overlay, delta(`<voice>${longClause} because`))
|
||||
// Open block crossed the early-speech threshold at a clause boundary.
|
||||
expect(overlay.voiceSegments).toEqual([longClause])
|
||||
overlay = applyOverlay(overlay, delta(' it tells you the root cause.</voice>'))
|
||||
// Remainder only — the early clause is not repeated.
|
||||
expect(overlay.voiceSegments).toEqual([longClause, 'because it tells you the root cause.'])
|
||||
})
|
||||
|
||||
it('does not emit early clauses from short open blocks', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('<voice>Sure, one sec'))
|
||||
expect(overlay.voiceSegments).toEqual([])
|
||||
overlay = applyOverlay(overlay, delta('.</voice>'))
|
||||
expect(overlay.voiceSegments).toEqual(['Sure, one sec.'])
|
||||
})
|
||||
|
||||
it('keeps segments but resets the scan on model_call_completed', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('<voice>one</voice>'))
|
||||
overlay = applyOverlay(overlay, completed(T1, 0, assistantText('<voice>one</voice>')))
|
||||
expect(overlay.voiceSegments).toEqual(['one'])
|
||||
expect(overlay.text).toBe('')
|
||||
overlay = applyOverlay(overlay, delta('<voice>two</voice>'))
|
||||
expect(overlay.voiceSegments).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('strips voice tags from the streaming message and exposes segments on state', () => {
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0)])
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('Plan: <voice>speak this</voice> rest'))
|
||||
const state = buildSessionChatState([turn], overlay)
|
||||
expect(state.currentAssistantMessage).toBe('Plan: speak this rest')
|
||||
expect(state.voiceSegments).toEqual(['speak this'])
|
||||
})
|
||||
|
||||
it('strips voice tags from persisted assistant messages', () => {
|
||||
const state = reduceTurn(
|
||||
completedTurnLog(T1, S1, 'q', 'Sure. <voice>Here you go.</voice> Done.'),
|
||||
)
|
||||
const items = buildTurnConversation(state)
|
||||
const assistant = items.find((i) => isChatMessage(i) && i.role === 'assistant')
|
||||
expect(isChatMessage(assistant!) && assistant.content).toBe('Sure. Here you go. Done.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTurnConversation', () => {
|
||||
it('maps user input, assistant text, and settled tool calls', () => {
|
||||
const call = assistantCalls(toolCallPart('tc1', 'echo', { x: 1 }))
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, user('run it')),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, call),
|
||||
invocation(T1, 'tc1', 'echo'),
|
||||
toolResult(T1, 'tc1', 'echo', { echoed: true }),
|
||||
requested(T1, 1, [
|
||||
'assistant:0',
|
||||
'toolResult:tc1',
|
||||
]),
|
||||
completed(T1, 1, assistantText('all done')),
|
||||
turnCompleted(T1, 'all done'),
|
||||
])
|
||||
const items = buildTurnConversation(state)
|
||||
expect(items.map((i) => (isToolCall(i) ? `tool:${i.name}:${i.status}` : isChatMessage(i) ? `${i.role}` : 'x'))).toEqual([
|
||||
'user',
|
||||
'tool:echo:completed',
|
||||
'assistant',
|
||||
])
|
||||
const tool = items.find(isToolCall)
|
||||
expect(tool?.result).toEqual({ echoed: true })
|
||||
expect(tool?.input).toEqual({ x: 1 })
|
||||
})
|
||||
|
||||
it('marks permission-pending tools pending and running tools running', () => {
|
||||
const state = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'), toolCallPart('r1', 'echo'))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'p1',
|
||||
toolName: 'executeCommand',
|
||||
request: { kind: 'command', commandNames: ['rm'] },
|
||||
},
|
||||
invocation(T1, 'r1', 'echo'),
|
||||
])
|
||||
const items = buildTurnConversation(state).filter(isToolCall)
|
||||
expect(items.map((i) => i.status)).toEqual(['pending', 'running'])
|
||||
})
|
||||
|
||||
it('uses the tool result envelope to determine error status', () => {
|
||||
const state = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(
|
||||
T1,
|
||||
0,
|
||||
assistantCalls(
|
||||
toolCallPart('flagged', 'echo'),
|
||||
toolCallPart('normal', 'echo'),
|
||||
),
|
||||
),
|
||||
invocation(T1, 'flagged', 'echo'),
|
||||
{
|
||||
type: 'tool_result',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'flagged',
|
||||
toolName: 'echo',
|
||||
source: 'sync',
|
||||
result: { output: 'boom', isError: true },
|
||||
},
|
||||
invocation(T1, 'normal', 'echo'),
|
||||
toolResult(T1, 'normal', 'echo', { success: false, message: 'payload data' }),
|
||||
])
|
||||
const items = buildTurnConversation(state).filter(isToolCall)
|
||||
expect(items.map((i) => i.status)).toEqual(['error', 'completed'])
|
||||
})
|
||||
|
||||
it('renders user attachments and a failed turn as an error item', () => {
|
||||
const input = {
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'see file' },
|
||||
{ type: 'attachment' as const, path: '/tmp/a.png', filename: 'a.png', mimeType: 'image/png' },
|
||||
],
|
||||
}
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, input as never),
|
||||
requested(T1, 0),
|
||||
{ type: 'model_call_failed', turnId: T1, ts: TS, modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed', turnId: T1, ts: TS, error: 'boom', usage: {} },
|
||||
])
|
||||
const items = buildTurnConversation(state)
|
||||
expect(isChatMessage(items[0]) && items[0].attachments?.[0].filename).toBe('a.png')
|
||||
expect(isErrorMessage(items[1]) && items[1].message).toBe('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSessionChatState', () => {
|
||||
it('composes the conversation across turns and derives processing flags', () => {
|
||||
const prior = reduceTurn(completedTurnLog('turn-1', S1, 'first?', 'first answer'))
|
||||
const latest = reduceTurn([
|
||||
created('turn-2', S1, user('second?')),
|
||||
requested('turn-2', 0),
|
||||
])
|
||||
const state = buildSessionChatState([prior, latest], emptyOverlay())
|
||||
expect(
|
||||
state.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['first?', 'first answer', 'second?'])
|
||||
expect(state.isProcessing).toBe(true) // latest turn idle = actively working
|
||||
expect(state.isThinking).toBe(true)
|
||||
})
|
||||
|
||||
it('is settled (not processing) when the latest turn is terminal', () => {
|
||||
const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a'))
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
expect(state.isProcessing).toBe(false)
|
||||
expect(state.isThinking).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes pending permissions as request events; waiting is not thinking', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand', { command: 'rm -rf /' }))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'p1',
|
||||
toolName: 'executeCommand',
|
||||
request: { kind: 'command', commandNames: ['rm'] },
|
||||
},
|
||||
{
|
||||
type: 'turn_suspended',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
pendingPermissions: [
|
||||
{ toolCallId: 'p1', toolName: 'executeCommand', request: { kind: 'command', commandNames: ['rm'] } },
|
||||
],
|
||||
pendingAsyncTools: [],
|
||||
usage: {},
|
||||
},
|
||||
])
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
const request = state.allPermissionRequests.get('p1')
|
||||
expect(request).toMatchObject({
|
||||
type: 'tool-permission-request',
|
||||
toolCall: { toolCallId: 'p1', toolName: 'executeCommand' },
|
||||
permission: { kind: 'command', commandNames: ['rm'] },
|
||||
})
|
||||
expect(state.isProcessing).toBe(true)
|
||||
expect(state.isThinking).toBe(false)
|
||||
})
|
||||
|
||||
it('maps human decisions to responses and classifier decisions to auto-decisions', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('h1', 'echo'), toolCallPart('c1', 'echo'))),
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'h1', toolName: 'echo', request: { kind: 'command', commandNames: ['x'] } },
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', request: { kind: 'command', commandNames: ['y'] } },
|
||||
{ type: 'tool_permission_resolved', turnId: T1, ts: TS, toolCallId: 'h1', decision: 'allow', source: 'human' },
|
||||
{ type: 'tool_permission_resolved', turnId: T1, ts: TS, toolCallId: 'c1', decision: 'deny', source: 'classifier', reason: 'risky' },
|
||||
{ type: 'tool_result', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', source: 'runtime', result: { output: 'denied', isError: true } },
|
||||
invocation(T1, 'h1', 'echo'),
|
||||
toolResult(T1, 'h1', 'echo'),
|
||||
])
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
expect(state.permissionResponses.get('h1')).toBe('approve')
|
||||
expect(state.autoPermissionDecisions.get('c1')).toMatchObject({
|
||||
decision: 'deny',
|
||||
reason: 'risky',
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes pending ask-human calls with question and options', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('ah1', 'ask-human', { question: 'Deploy?', options: ['Yes', 'No'] }))),
|
||||
{
|
||||
type: 'tool_invocation_requested',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'ah1',
|
||||
toolId: 'builtin:ask-human',
|
||||
toolName: 'ask-human',
|
||||
execution: 'async',
|
||||
input: { question: 'Deploy?', options: ['Yes', 'No'] },
|
||||
},
|
||||
])
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
expect(state.pendingAskHumanRequests.get('ah1')).toMatchObject({
|
||||
query: 'Deploy?',
|
||||
options: ['Yes', 'No'],
|
||||
})
|
||||
expect(state.isThinking).toBe(false) // waiting on the human, no shimmer
|
||||
})
|
||||
|
||||
it('stitches live tool output onto the matching tool item', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('tc1', 'executeCommand'))),
|
||||
invocation(T1, 'tc1', 'executeCommand'),
|
||||
])
|
||||
const overlay = { ...emptyOverlay(), toolOutput: { tc1: 'partial output' } }
|
||||
const state = buildSessionChatState([turn], overlay)
|
||||
const tool = state.conversation.filter(isToolCall)[0]
|
||||
expect(tool.streamingOutput).toBe('partial output')
|
||||
})
|
||||
|
||||
it('surfaces streaming text as currentAssistantMessage', () => {
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0)])
|
||||
const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' })
|
||||
expect(state.currentAssistantMessage).toBe('typing…')
|
||||
})
|
||||
})
|
||||
395
apps/x/apps/renderer/src/lib/session-chat/turn-view.ts
Normal file
395
apps/x/apps/renderer/src/lib/session-chat/turn-view.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import type { z } from 'zod'
|
||||
import type {
|
||||
AskHumanRequestEvent,
|
||||
ToolPermissionAutoDecisionEvent,
|
||||
ToolPermissionMetadata,
|
||||
ToolPermissionRequestEvent,
|
||||
} from '@x/shared/src/runs.js'
|
||||
import {
|
||||
deriveTurnStatus,
|
||||
outstandingAsyncTools,
|
||||
outstandingPermissions,
|
||||
type ToolCallState,
|
||||
type TurnState,
|
||||
type TurnStreamEvent,
|
||||
} from '@x/shared/src/turns.js'
|
||||
import type {
|
||||
ChatMessage,
|
||||
ConversationItem,
|
||||
ErrorMessage,
|
||||
MessageAttachment,
|
||||
PermissionResponse,
|
||||
ToolCall,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
||||
// Pure derivations from reduced turn state (+ the ephemeral live overlay) to
|
||||
// the view shapes the existing chat components already consume. No IPC, no
|
||||
// React — everything here is unit-testable with plain state values.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live overlay: ephemeral streaming buffers (turn-runtime-design.md §14.4).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LiveOverlay = {
|
||||
text: string
|
||||
reasoning: string
|
||||
toolOutput: Record<string, string>
|
||||
// Speakable segments seen while streaming, in order, monotonically growing
|
||||
// for the lifetime of the overlay (i.e. one active turn). Usually the
|
||||
// contents of completed <voice>…</voice> blocks, but a long still-open
|
||||
// block may emit an early clause (see EARLY_SPEECH_MIN_CHARS) so speech
|
||||
// can start before the sentence finishes generating. Consumers speak
|
||||
// segments beyond what they've already spoken; the overlay reset on turn
|
||||
// switch starts a fresh list.
|
||||
voiceSegments: string[]
|
||||
// Scan cursor into `text` — everything before it has been checked for
|
||||
// complete voice blocks.
|
||||
voiceScanIndex: number
|
||||
// Chars of the currently-open voice block's content already emitted as an
|
||||
// early clause — the block's remainder (on close) excludes them.
|
||||
voicePartialConsumed: number
|
||||
}
|
||||
|
||||
export const emptyOverlay = (): LiveOverlay => ({
|
||||
text: '',
|
||||
reasoning: '',
|
||||
toolOutput: {},
|
||||
voiceSegments: [],
|
||||
voiceScanIndex: 0,
|
||||
voicePartialConsumed: 0,
|
||||
})
|
||||
|
||||
// The model emits <voice>…</voice> around speakable text when voice output
|
||||
// is enabled; tags are never shown to the user.
|
||||
export function stripVoiceTags(text: string): string {
|
||||
return text.replace(/<\/?voice>/g, '')
|
||||
}
|
||||
|
||||
const VOICE_BLOCK = /<voice>([\s\S]*?)<\/voice>/g
|
||||
const VOICE_OPEN_TAG = '<voice>'
|
||||
|
||||
// Early speech: once an open block has this many unconsumed chars, its last
|
||||
// complete clause is emitted immediately instead of waiting for </voice> —
|
||||
// TTS starts on the first clause while the rest of the sentence generates.
|
||||
const EARLY_SPEECH_MIN_CHARS = 60
|
||||
// ...but never emit a fragment shorter than this (prosody suffers).
|
||||
const EARLY_SPEECH_MIN_EMIT = 30
|
||||
// Clause boundaries (punctuation, optionally inside closing quote/paren,
|
||||
// followed by whitespace or end-of-buffer).
|
||||
const CLAUSE_BOUNDARY = /[,;:.!?…—]["')\]]*(?=\s|$)/g
|
||||
|
||||
// Accumulates deltas; canonical durable events supersede the buffers (the
|
||||
// committed transcript now contains what was streaming).
|
||||
export function applyOverlay(overlay: LiveOverlay, event: TurnStreamEvent): LiveOverlay {
|
||||
switch (event.type) {
|
||||
case 'text_delta': {
|
||||
const text = overlay.text + event.delta
|
||||
// Extract complete voice blocks past the scan cursor. Incomplete
|
||||
// blocks (opening tag seen, closing not yet) stay unconsumed until a
|
||||
// later delta completes them. The first complete block may have had an
|
||||
// early clause emitted while it was open — skip those chars.
|
||||
const segments: string[] = []
|
||||
let scanIndex = overlay.voiceScanIndex
|
||||
let partialConsumed = overlay.voicePartialConsumed
|
||||
VOICE_BLOCK.lastIndex = scanIndex
|
||||
for (let m = VOICE_BLOCK.exec(text); m; m = VOICE_BLOCK.exec(text)) {
|
||||
const content = m[1].slice(partialConsumed).trim()
|
||||
partialConsumed = 0
|
||||
if (content) segments.push(content)
|
||||
scanIndex = m.index + m[0].length
|
||||
}
|
||||
|
||||
// Early speech: if a voice block is still open and has accumulated a
|
||||
// long unconsumed run, emit its last complete clause now — speech can
|
||||
// start while the rest of the sentence is still generating.
|
||||
const openIdx = text.indexOf(VOICE_OPEN_TAG, scanIndex)
|
||||
if (openIdx !== -1) {
|
||||
const unconsumed = text.slice(openIdx + VOICE_OPEN_TAG.length + partialConsumed)
|
||||
if (unconsumed.length >= EARLY_SPEECH_MIN_CHARS) {
|
||||
let lastBoundaryEnd = -1
|
||||
CLAUSE_BOUNDARY.lastIndex = 0
|
||||
for (let b = CLAUSE_BOUNDARY.exec(unconsumed); b; b = CLAUSE_BOUNDARY.exec(unconsumed)) {
|
||||
lastBoundaryEnd = b.index + b[0].length
|
||||
}
|
||||
if (lastBoundaryEnd >= EARLY_SPEECH_MIN_EMIT) {
|
||||
const clause = unconsumed.slice(0, lastBoundaryEnd).trim()
|
||||
if (clause) segments.push(clause)
|
||||
partialConsumed += lastBoundaryEnd
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No open block — any partial bookkeeping belongs to a block that
|
||||
// has since closed.
|
||||
partialConsumed = 0
|
||||
}
|
||||
|
||||
return {
|
||||
...overlay,
|
||||
text,
|
||||
...(segments.length > 0
|
||||
? { voiceSegments: [...overlay.voiceSegments, ...segments] }
|
||||
: {}),
|
||||
voiceScanIndex: scanIndex,
|
||||
voicePartialConsumed: partialConsumed,
|
||||
}
|
||||
}
|
||||
case 'reasoning_delta':
|
||||
return { ...overlay, reasoning: overlay.reasoning + event.delta }
|
||||
case 'model_call_completed':
|
||||
return { ...overlay, text: '', reasoning: '', voiceScanIndex: 0, voicePartialConsumed: 0 }
|
||||
case 'tool_progress': {
|
||||
const progress = event.progress
|
||||
if (
|
||||
progress &&
|
||||
typeof progress === 'object' &&
|
||||
!Array.isArray(progress) &&
|
||||
(progress as { kind?: unknown }).kind === 'tool-output' &&
|
||||
typeof (progress as { chunk?: unknown }).chunk === 'string'
|
||||
) {
|
||||
const chunk = (progress as { chunk: string }).chunk
|
||||
return {
|
||||
...overlay,
|
||||
toolOutput: {
|
||||
...overlay.toolOutput,
|
||||
[event.toolCallId]: (overlay.toolOutput[event.toolCallId] ?? '') + chunk,
|
||||
},
|
||||
}
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
case 'tool_result': {
|
||||
if (!(event.toolCallId in overlay.toolOutput)) return overlay
|
||||
const toolOutput = { ...overlay.toolOutput }
|
||||
delete toolOutput[event.toolCallId]
|
||||
return { ...overlay, toolOutput }
|
||||
}
|
||||
default:
|
||||
return overlay
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type UserContent = TurnState['definition']['input']['content']
|
||||
|
||||
function extractText(content: UserContent): string {
|
||||
if (typeof content === 'string') return content
|
||||
return content
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function extractAttachments(content: UserContent): MessageAttachment[] | undefined {
|
||||
if (typeof content === 'string') return undefined
|
||||
const attachments = content.flatMap((part) =>
|
||||
part.type === 'attachment'
|
||||
? [
|
||||
{
|
||||
path: part.path,
|
||||
filename: part.filename,
|
||||
mimeType: part.mimeType,
|
||||
...(part.size === undefined ? {} : { size: part.size }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
return attachments.length > 0 ? attachments : undefined
|
||||
}
|
||||
|
||||
function toolStatus(tc: ToolCallState): ToolCall['status'] {
|
||||
if (tc.result) return tc.result.result.isError ? 'error' : 'completed'
|
||||
if (tc.permission && !tc.permission.resolved) return 'pending'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
// One turn's contribution to the conversation: the user input, then per
|
||||
// completed model call its text and tool calls (with live status/results).
|
||||
export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
||||
const items: ConversationItem[] = []
|
||||
const turnId = state.definition.turnId
|
||||
let seq = 0
|
||||
const ts = () => Date.parse(state.definition.ts) + seq++
|
||||
|
||||
const userText = extractText(state.definition.input.content)
|
||||
const attachments = extractAttachments(state.definition.input.content)
|
||||
if (userText || attachments) {
|
||||
items.push({
|
||||
id: `${turnId}:user`,
|
||||
role: 'user',
|
||||
content: userText,
|
||||
timestamp: ts(),
|
||||
...(attachments ? { attachments } : {}),
|
||||
} satisfies ChatMessage)
|
||||
}
|
||||
|
||||
const toolCallsById = new Map(state.toolCalls.map((tc) => [tc.toolCallId, tc]))
|
||||
for (const call of state.modelCalls) {
|
||||
if (call.response === undefined) continue
|
||||
const content = call.response.content
|
||||
// Voice tags are model-facing markup, never shown (parity with the
|
||||
// legacy path's display-time strip).
|
||||
const text = stripVoiceTags(
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
)
|
||||
if (text) {
|
||||
items.push({
|
||||
id: `${turnId}:a${call.index}`,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: ts(),
|
||||
} satisfies ChatMessage)
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part.type !== 'tool-call') continue
|
||||
const tc = toolCallsById.get(part.toolCallId)
|
||||
items.push({
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: part.arguments as ToolCall['input'],
|
||||
...(tc?.result ? { result: tc.result.result.output as ToolCall['result'] } : {}),
|
||||
status: tc ? toolStatus(tc) : 'running',
|
||||
timestamp: ts(),
|
||||
} satisfies ToolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.terminal?.type === 'turn_failed') {
|
||||
items.push({
|
||||
id: `${turnId}:error`,
|
||||
kind: 'error',
|
||||
message: state.terminal.error,
|
||||
timestamp: ts(),
|
||||
} satisfies ErrorMessage)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session chat state (superset of the ChatTabViewState fields)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PermMeta = z.infer<typeof ToolPermissionMetadata>
|
||||
|
||||
export type SessionChatState = {
|
||||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
// See LiveOverlay.voiceSegments.
|
||||
voiceSegments: string[]
|
||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||
permissionResponses: Map<string, PermissionResponse>
|
||||
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
|
||||
// Composer blocked / Stop shown until the latest turn settles (waiting on a
|
||||
// permission or ask-human still counts as processing).
|
||||
isProcessing: boolean
|
||||
// Actively working (non-terminal, nothing waiting on the user) — drives the
|
||||
// "Thinking…" shimmer; deliberately false under permission/ask-human cards.
|
||||
isThinking: boolean
|
||||
}
|
||||
|
||||
function toolCallPartOf(tc: ToolCallState) {
|
||||
return {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
arguments: tc.input,
|
||||
}
|
||||
}
|
||||
|
||||
// Compose the whole session's conversation: prior (settled) turns plus the
|
||||
// latest turn, with the live overlay stitched onto the latest turn's items.
|
||||
export function buildSessionChatState(
|
||||
turns: TurnState[],
|
||||
overlay: LiveOverlay,
|
||||
): SessionChatState {
|
||||
const conversation: ConversationItem[] = []
|
||||
for (const turn of turns) {
|
||||
conversation.push(...buildTurnConversation(turn))
|
||||
}
|
||||
for (let i = 0; i < conversation.length; i++) {
|
||||
const item = conversation[i]
|
||||
if ('name' in item && overlay.toolOutput[item.id]) {
|
||||
conversation[i] = { ...item, streamingOutput: overlay.toolOutput[item.id] }
|
||||
}
|
||||
}
|
||||
|
||||
const latest = turns[turns.length - 1]
|
||||
const status = latest ? deriveTurnStatus(latest) : undefined
|
||||
const latestTurnId = latest?.definition.turnId ?? ''
|
||||
|
||||
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
const permissionResponses = new Map<string, PermissionResponse>()
|
||||
const autoPermissionDecisions = new Map<
|
||||
string,
|
||||
z.infer<typeof ToolPermissionAutoDecisionEvent>
|
||||
>()
|
||||
const pendingAskHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||
|
||||
if (latest) {
|
||||
for (const tc of outstandingPermissions(latest)) {
|
||||
allPermissionRequests.set(tc.toolCallId, {
|
||||
runId: latestTurnId,
|
||||
type: 'tool-permission-request',
|
||||
subflow: [],
|
||||
toolCall: toolCallPartOf(tc),
|
||||
permission: tc.permission?.required.request as PermMeta,
|
||||
})
|
||||
}
|
||||
for (const tc of latest.toolCalls) {
|
||||
const resolved = tc.permission?.resolved
|
||||
if (!resolved) continue
|
||||
if (resolved.source === 'human') {
|
||||
permissionResponses.set(tc.toolCallId, resolved.decision === 'allow' ? 'approve' : 'deny')
|
||||
} else if (resolved.source === 'classifier') {
|
||||
autoPermissionDecisions.set(tc.toolCallId, {
|
||||
runId: latestTurnId,
|
||||
type: 'tool-permission-auto-decision',
|
||||
subflow: [],
|
||||
toolCallId: tc.toolCallId,
|
||||
toolCall: toolCallPartOf(tc),
|
||||
permission: tc.permission?.required.request as PermMeta,
|
||||
decision: resolved.decision,
|
||||
reason: resolved.reason ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const tc of outstandingAsyncTools(latest)) {
|
||||
if (tc.toolName !== 'ask-human') continue
|
||||
const input = (tc.input ?? {}) as { question?: unknown; options?: unknown }
|
||||
pendingAskHumanRequests.set(tc.toolCallId, {
|
||||
runId: latestTurnId,
|
||||
type: 'ask-human-request',
|
||||
toolCallId: tc.toolCallId,
|
||||
subflow: [],
|
||||
query: typeof input.question === 'string' ? input.question : '',
|
||||
...(Array.isArray(input.options) && input.options.every((o) => typeof o === 'string')
|
||||
? { options: input.options }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const settled = status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
return {
|
||||
conversation,
|
||||
currentAssistantMessage: stripVoiceTags(overlay.text),
|
||||
voiceSegments: overlay.voiceSegments,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
isProcessing: latest !== undefined && !settled,
|
||||
isThinking: status === 'idle',
|
||||
}
|
||||
}
|
||||
109
apps/x/apps/renderer/src/lib/tour-sounds.ts
Normal file
109
apps/x/apps/renderer/src/lib/tour-sounds.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Tiny synthesized sound effects for the product tour — oar splashes, dock
|
||||
* bumps, and an arrival fanfare. Everything is generated with Web Audio
|
||||
* oscillators/noise so no audio assets are needed. All methods fail silently
|
||||
* if audio is unavailable.
|
||||
*/
|
||||
export class TourSounds {
|
||||
private ctx: AudioContext | null = null
|
||||
private master: GainNode | null = null
|
||||
|
||||
private ensure(): AudioContext | null {
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext()
|
||||
this.master = this.ctx.createGain()
|
||||
this.master.gain.value = 0.5
|
||||
this.master.connect(this.ctx.destination)
|
||||
}
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume()
|
||||
return this.ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Short filtered-noise burst with a falling pitch — an oar dipping. */
|
||||
splash() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const dur = 0.22
|
||||
const noise = ctx.createBufferSource()
|
||||
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1
|
||||
noise.buffer = buffer
|
||||
|
||||
const filter = ctx.createBiquadFilter()
|
||||
filter.type = 'bandpass'
|
||||
filter.Q.value = 1.2
|
||||
filter.frequency.setValueAtTime(1600, ctx.currentTime)
|
||||
filter.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + dur)
|
||||
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.14, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur)
|
||||
|
||||
noise.connect(filter).connect(gain).connect(this.master)
|
||||
noise.start()
|
||||
noise.stop(ctx.currentTime + dur)
|
||||
}
|
||||
|
||||
/** Soft low thump — the boat nudging a dock. */
|
||||
bump() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(150, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + 0.18)
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.2, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25)
|
||||
osc.connect(gain).connect(this.master)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.3)
|
||||
}
|
||||
|
||||
/** Gentle high blip — an email landing in the boat. */
|
||||
ding() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(880, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.05)
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.08, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
|
||||
osc.connect(gain).connect(this.master)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.35)
|
||||
}
|
||||
|
||||
/** Little four-note arpeggio for the tour finale. */
|
||||
fanfare() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5] // C5 E5 G5 C6
|
||||
notes.forEach((freq, i) => {
|
||||
const start = ctx.currentTime + i * 0.13
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.value = freq
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(0.16, start + 0.02)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, start + (i === notes.length - 1 ? 0.7 : 0.3))
|
||||
osc.connect(gain).connect(this.master!)
|
||||
osc.start(start)
|
||||
osc.stop(start + 0.8)
|
||||
})
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.ctx?.close().catch(() => {})
|
||||
this.ctx = null
|
||||
this.master = null
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { PostHogProvider } from 'posthog-js/react'
|
|||
import type { CaptureResult } from 'posthog-js'
|
||||
import { ThemeProvider } from '@/contexts/theme-context'
|
||||
import { configureAnalyticsContext } from './lib/analytics'
|
||||
import { VideoPopout } from '@/components/video-popout'
|
||||
|
||||
// Fetch the stable installation ID from main so renderer + main share one
|
||||
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
||||
|
|
@ -57,4 +58,14 @@ async function bootstrap() {
|
|||
// The loaded callback applies api_url/app_version once PostHog has initialized.
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
// The video-mode popout window loads the same bundle with a hash route and
|
||||
// renders only the mini-call UI — no analytics or app bootstrap needed.
|
||||
if (window.location.hash === '#video-popout') {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<VideoPopout />
|
||||
</StrictMode>,
|
||||
)
|
||||
} else {
|
||||
bootstrap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1551,9 +1551,9 @@
|
|||
/* Compose / draft box */
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose {
|
||||
margin-top: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1562,13 +1562,13 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-addr {
|
||||
|
|
@ -1592,7 +1592,7 @@
|
|||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-footer {
|
||||
|
|
@ -1600,7 +1600,7 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
|
|
@ -1615,35 +1615,33 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
width: fit-content;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
color: var(--foreground);
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary {
|
||||
color: #fff;
|
||||
background: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover {
|
||||
background: #1765cc;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
|
||||
color: #fff;
|
||||
background: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
border-color: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-error,
|
||||
|
|
@ -1690,9 +1688,8 @@
|
|||
|
||||
/* ---- Emails inbox block (language-emails) ---- */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-card {
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
/* No font-family override — inherit the editor's app font (ui-sans-serif /
|
||||
system-ui) so the inbox block matches the rest of the Rowboat UI. */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-title {
|
||||
font-size: 14px;
|
||||
|
|
@ -1768,7 +1765,6 @@
|
|||
.tiptap-editor .ProseMirror .email-inbox-sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
1
apps/x/apps/renderer/src/test/setup.ts
Normal file
1
apps/x/apps/renderer/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import path from "path"
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
|
|
@ -18,4 +18,10 @@ export default defineConfig({
|
|||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
"deps": "npm run shared && npm run core && npm run preload",
|
||||
"main": "wait-on http://localhost:5173 && cd apps/main && npm run build && npm run start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "npm run shared && npm run test:shared && npm run test:core && npm run test:renderer",
|
||||
"test:shared": "cd packages/shared && npm test",
|
||||
"test:core": "cd packages/core && npm test",
|
||||
"test:renderer": "cd apps/renderer && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
|
|
@ -26,4 +30,4 @@
|
|||
"typescript-eslint": "^8.50.1",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
561
apps/x/packages/core/docs/session-design.md
Normal file
561
apps/x/packages/core/docs/session-design.md
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
# Session Layer Technical Specification
|
||||
|
||||
Status: design complete for the session layer v1. No implementation exists
|
||||
yet.
|
||||
|
||||
This document specifies the session layer that sits above the turn runtime
|
||||
defined in `turn-runtime-design.md`. That document is assumed context; this
|
||||
one does not restate turn semantics.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
The session layer must:
|
||||
|
||||
1. Own conversations composed of ordered turns.
|
||||
2. Persist each session as one append-only JSONL file with the same
|
||||
validation discipline as turn files.
|
||||
3. Enforce one active turn per session.
|
||||
4. Assemble each turn's context as a reference to the previous turn.
|
||||
5. Maintain an in-memory index for listing, sorting, and filtering sessions,
|
||||
updated write-through and rebuilt by scanning at startup.
|
||||
6. Route external inputs — permission decisions, ask-human answers, async
|
||||
tool results — to the correct turn through dedicated APIs.
|
||||
7. Forward live turn events to the renderer over IPC.
|
||||
8. Provide headless standalone turns outside any session.
|
||||
|
||||
## 2. Non-goals (v1)
|
||||
|
||||
- Queued user messages. The committed future shape is in section 12.1.
|
||||
- Steering / mid-turn message injection (section 12.4).
|
||||
- Session-scoped permission grants ("always allow for this chat"); every
|
||||
applicable tool call prompts in v1 (section 12.2).
|
||||
- Context compaction; a viable mechanism sketch is recorded in section 12.3.
|
||||
V1 behavior on context overflow is the turn-level model failure.
|
||||
- LLM auto-titling (section 12.6).
|
||||
- A persisted index cache; startup always scans (section 12.5).
|
||||
- Cross-process coordination. A single main process is enforced.
|
||||
- Data migration from the current runs system. Old conversations are not
|
||||
converted; the old code path remains readable until it is deleted.
|
||||
- Session list pagination. The index is in-memory and shipped whole.
|
||||
|
||||
## 3. Terminology
|
||||
|
||||
A **session** is a durable, ordered chain of turns plus presentation
|
||||
metadata (title). Conversation content lives exclusively in turn files; the
|
||||
session file stores turn references with denormalized metadata.
|
||||
|
||||
The **index** is an in-memory projection over all session files, used for
|
||||
the session list UI. It is never a source of truth.
|
||||
|
||||
A **standalone turn** is a turn with `sessionId: null`, created outside any
|
||||
session by headless callers. Standalone turns do not appear in the index.
|
||||
|
||||
## 4. Storage design
|
||||
|
||||
### 4.1 File location
|
||||
|
||||
Session files live under:
|
||||
|
||||
```text
|
||||
WorkDir/storage/sessions/YYYY/MM/DD/<sessionId>.jsonl
|
||||
```
|
||||
|
||||
Session IDs come from the existing
|
||||
`IMonotonicallyIncreasingIdGenerator`, and the repository derives the
|
||||
date-partitioned path from the ID exactly as the turn repository does,
|
||||
including format validation and path-traversal rejection.
|
||||
|
||||
### 4.2 File rules
|
||||
|
||||
Identical discipline to turn files:
|
||||
|
||||
- The first line is always `session_created` with `schemaVersion: 1`.
|
||||
- Every event contains `sessionId` and an ISO timestamp `ts`.
|
||||
- Physical line order is authoritative.
|
||||
- Reads validate every line strictly; any malformed line makes the session
|
||||
corrupt; no truncation, repair, or skipping.
|
||||
- Unknown schema versions and unknown event types fail loudly. Future
|
||||
additive event types (queueing, grants, compaction) arrive as a schema
|
||||
version bump; the reducer will accept old and new versions and write the
|
||||
newest.
|
||||
- Appends are awaited but not explicitly `fsync`ed.
|
||||
|
||||
### 4.3 Repository contract
|
||||
|
||||
```ts
|
||||
interface ISessionRepo {
|
||||
create(event: SessionCreated): Promise<void>;
|
||||
read(sessionId: string): Promise<SessionEvent[]>;
|
||||
append(sessionId: string, events: SessionEvent[]): Promise<void>;
|
||||
withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
|
||||
listSessionIds(): Promise<string[]>;
|
||||
delete(sessionId: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
- `create` fails if the file exists.
|
||||
- `listSessionIds` enumerates the partition directories for the startup
|
||||
scan.
|
||||
- `delete` removes the session file only. Turn files referenced by the
|
||||
session are left in place as harmless orphans (see section 9, deletion).
|
||||
- `withLock` is in-process per-session exclusion, mirroring the turn repo.
|
||||
|
||||
## 5. Event schemas
|
||||
|
||||
All session event schemas live in `@x/shared` alongside the turn schemas.
|
||||
|
||||
```ts
|
||||
interface BaseSessionEvent {
|
||||
sessionId: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface SessionCreated extends BaseSessionEvent {
|
||||
type: "session_created";
|
||||
schemaVersion: 1;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface SessionTurnAppended extends BaseSessionEvent {
|
||||
type: "turn_appended";
|
||||
turnId: string;
|
||||
sessionSeq: number; // 1-based position of the turn in the session
|
||||
agentId: string;
|
||||
model: ModelDescriptor; // resolved provider/model for the turn
|
||||
}
|
||||
|
||||
interface SessionTitleChanged extends BaseSessionEvent {
|
||||
type: "title_changed";
|
||||
title: string;
|
||||
}
|
||||
|
||||
type SessionEvent =
|
||||
| SessionCreated
|
||||
| SessionTurnAppended
|
||||
| SessionTitleChanged;
|
||||
```
|
||||
|
||||
`turn_appended` deliberately denormalizes `agentId` and `model` from the
|
||||
turn so the index can fold from session files without opening turn files.
|
||||
The turn file remains authoritative for the turn's actual configuration.
|
||||
|
||||
The session file never mirrors turn outcomes. Turn lifecycle facts live only
|
||||
in turn files; deriving "is this session busy/suspended/failed" reads the
|
||||
latest turn (section 8).
|
||||
|
||||
## 6. Session reducer
|
||||
|
||||
`@x/shared` owns one pure reducer shared by core and renderer:
|
||||
|
||||
```ts
|
||||
function reduceSession(events: SessionEvent[]): SessionState;
|
||||
|
||||
interface SessionState {
|
||||
definition: SessionCreated;
|
||||
title?: string;
|
||||
turns: Array<{
|
||||
turnId: string;
|
||||
sessionSeq: number;
|
||||
agentId: string;
|
||||
model: ModelDescriptor;
|
||||
ts: string;
|
||||
}>;
|
||||
latestTurnId?: string;
|
||||
createdAt: string; // definition.ts
|
||||
updatedAt: string; // ts of the last event
|
||||
}
|
||||
```
|
||||
|
||||
Invariants (violations throw, as with the turn reducer):
|
||||
|
||||
- `session_created` is present, first, and unique.
|
||||
- All event `sessionId` values match.
|
||||
- `sessionSeq` is strictly increasing starting at 1, with no gaps.
|
||||
- `turnId` values are unique.
|
||||
- Unsupported schema versions and unknown event types fail loudly.
|
||||
|
||||
## 7. Write ordering and consistency
|
||||
|
||||
Per user message, the session layer performs, in order:
|
||||
|
||||
1. `turnRuntime.createTurn(...)` — the turn file is created.
|
||||
2. `sessionRepo.append(turn_appended)` — the session references the turn.
|
||||
3. `advanceTurn(...)` — execution begins.
|
||||
|
||||
Rules:
|
||||
|
||||
- A crash between steps 1 and 2 leaves an orphan turn file: unreferenced,
|
||||
never advanced, and benign. Turns are only ever found by reference, so an
|
||||
orphan is invisible. V1 does not garbage-collect orphans.
|
||||
- The reverse order is forbidden: a `turn_appended` referencing a turn file
|
||||
that was never created would be a dangling reference, which is corruption.
|
||||
- Step 2 precedes step 3 so that a turn that is executing is always already
|
||||
referenced by its session.
|
||||
- Session-file appends happen under the session lock; turn-file appends are
|
||||
the turn runtime's concern.
|
||||
|
||||
## 8. In-memory index
|
||||
|
||||
### 8.1 Shape
|
||||
|
||||
```ts
|
||||
interface SessionIndexEntry {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
turnCount: number;
|
||||
lastAgentId?: string;
|
||||
lastModel?: ModelDescriptor;
|
||||
latestTurnId?: string;
|
||||
latestTurnStatus:
|
||||
| "none" // session has no turns yet
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "suspended" // durable suspension: pending permissions/async tools
|
||||
| "idle"; // non-terminal, not suspended: interrupted by a crash
|
||||
}
|
||||
```
|
||||
|
||||
`latestTurnStatus` is derived from the latest turn's reduced state, using
|
||||
the same derivation everywhere: terminal event kind if present, else
|
||||
`suspended` if a suspension with outstanding work is the resting state, else
|
||||
`idle`. Whether a turn is *actively processing right now* is not in the
|
||||
index; it is ephemeral bus state (`turn-processing-start/end`), per the turn
|
||||
specification.
|
||||
|
||||
### 8.2 Startup scan
|
||||
|
||||
1. `listSessionIds()`.
|
||||
2. For each session: read and reduce the session file, producing the entry's
|
||||
session-derived fields.
|
||||
3. For each session with turns: read and reduce the latest turn file only,
|
||||
producing `latestTurnStatus`.
|
||||
4. Publish the completed index to the renderer.
|
||||
|
||||
A corrupt session file or corrupt latest-turn file does not abort startup:
|
||||
the entry is surfaced in an errored state (identifiable in the UI, excluded
|
||||
from normal interaction) and the scan continues.
|
||||
|
||||
### 8.3 Maintenance
|
||||
|
||||
- Every session mutation updates the entry in the same code path that
|
||||
appends to the session file (write-through), then publishes a
|
||||
`session-index-changed` event on the application bus.
|
||||
- When an `advanceTurn` outcome settles, the session layer updates
|
||||
`latestTurnStatus` and publishes `session-index-changed`.
|
||||
- There is no filesystem watcher. Out-of-band edits to session or turn files
|
||||
while the app runs are unsupported; offline changes are reconciled by the
|
||||
next startup scan.
|
||||
- The main process enforces single-instance via
|
||||
`app.requestSingleInstanceLock()`; all locking in both layers is
|
||||
in-process.
|
||||
|
||||
## 9. Sessions API
|
||||
|
||||
```ts
|
||||
interface SendMessageConfig {
|
||||
agent: RequestedAgent; // agent id + optional model override
|
||||
autoPermission?: boolean; // default false
|
||||
maxModelCalls?: number; // default per turn spec
|
||||
}
|
||||
|
||||
interface ISessions {
|
||||
createSession(input?: { title?: string }): Promise<string>;
|
||||
listSessions(): SessionIndexEntry[];
|
||||
getSession(sessionId: string): Promise<SessionState>;
|
||||
getTurn(turnId: string): Promise<Turn>; // passthrough to turn runtime
|
||||
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
input: UserMessage,
|
||||
config: SendMessageConfig,
|
||||
): Promise<{ turnId: string }>;
|
||||
|
||||
respondToPermission(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
decision: "allow" | "deny",
|
||||
metadata?: JsonValue,
|
||||
): Promise<void>;
|
||||
|
||||
respondToAskHuman(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
answer: string,
|
||||
): Promise<void>;
|
||||
|
||||
deliverAsyncToolResult(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
result: ToolResultData,
|
||||
): Promise<void>;
|
||||
|
||||
stopTurn(turnId: string, reason?: string): Promise<void>;
|
||||
resumeTurn(sessionId: string): Promise<void>;
|
||||
|
||||
setTitle(sessionId: string, title: string): Promise<void>;
|
||||
deleteSession(sessionId: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.1 sendMessage
|
||||
|
||||
Under the per-session lock:
|
||||
|
||||
1. Read and reduce the session.
|
||||
2. If the session has turns, read and reduce the latest turn. If it is
|
||||
non-terminal — running, suspended, or idle — reject with a typed
|
||||
`TurnNotSettledError`. There is no implicit queueing, steering, or
|
||||
cancel-and-replace, and no implicit routing to a pending ask-human.
|
||||
3. Build context: `[]` (inline, empty) for the first turn, else
|
||||
`{ previousTurnId: latestTurnId }`.
|
||||
4. Create the turn: config from `SendMessageConfig` lands on the turn
|
||||
(`humanAvailable: true` always, for session turns). Sessions store no
|
||||
configuration; every turn is self-describing.
|
||||
5. Append `turn_appended` with the next `sessionSeq` and denormalized
|
||||
agent/model.
|
||||
6. If the session has no title, append `title_changed` derived from the
|
||||
truncated first user message.
|
||||
7. Start `advanceTurn` in the background; consume its events (section 10).
|
||||
8. Return `{ turnId }` immediately. The renderer follows progress through
|
||||
events, not the return value.
|
||||
|
||||
Continuation after a failed or exhausted (`code: "model-call-limit"`) turn
|
||||
is just `sendMessage`: failed turns are terminal, and the new turn's context
|
||||
reference includes the failed turn's structurally complete transcript.
|
||||
|
||||
### 9.2 External inputs
|
||||
|
||||
`respondToPermission`, `respondToAskHuman`, and `deliverAsyncToolResult`
|
||||
each translate to one `advanceTurn(turnId, input)` call with the
|
||||
corresponding `TurnExternalInput`. `respondToAskHuman` is the dedicated
|
||||
endpoint for the `ask-human` tool — a thin wrapper over
|
||||
`async_tool_result` — and is deliberately separate from `sendMessage`.
|
||||
Validation (unknown call, already-resolved call, terminal turn) is the turn
|
||||
runtime's job; the session layer passes its errors through.
|
||||
|
||||
### 9.3 stopTurn and resumeTurn
|
||||
|
||||
- `stopTurn` cancels via the turn runtime: aborting the active invocation's
|
||||
signal if one is running, else advancing a suspended turn with a `cancel`
|
||||
input.
|
||||
- `resumeTurn` re-enters the latest turn with no input — the turn spec's
|
||||
recovery entry point — for turns left `idle` by a crash. There is no
|
||||
automatic resume sweep at startup: recovery re-issues interrupted model
|
||||
calls, so resumption must be an explicit user action. Suspended turns need
|
||||
no resumption; they advance when their inputs arrive.
|
||||
|
||||
### 9.4 Deletion
|
||||
|
||||
`deleteSession` removes the session file and the index entry, and publishes
|
||||
`session-index-changed`. Referenced turn files are retained as orphans:
|
||||
turns are only discoverable by reference, so orphaned files are inert.
|
||||
Deleting an entity's file is not a violation of append-only discipline,
|
||||
which governs mutation of live logs, not their removal.
|
||||
|
||||
## 10. Event forwarding and live UI
|
||||
|
||||
1. For every `advanceTurn` it initiates, the session layer consumes
|
||||
`TurnExecution.events` and forwards each `TurnStreamEvent` — tagged with
|
||||
`sessionId` — through the application bus to renderer windows over one
|
||||
IPC channel (`sessions:events`).
|
||||
2. When `outcome` settles, the session layer updates the index entry and
|
||||
publishes `session-index-changed`.
|
||||
3. The renderer follows the turn spec's historical/live pattern: fetch
|
||||
turns via `getTurn`, run the shared `reduceTurn` per turn, compose the
|
||||
session timeline turn-by-turn (each turn renders its input and its own
|
||||
activity; the referenced prefix is never re-rendered from context),
|
||||
append live durable events and re-reduce, and keep text/reasoning deltas
|
||||
in an ephemeral overlay cleared by canonical responses.
|
||||
4. Pending approvals and ask-human prompts render from the suspended turn's
|
||||
reduced state, so they survive restarts without any session-layer
|
||||
bookkeeping.
|
||||
|
||||
## 11. Headless standalone turns
|
||||
|
||||
A helper covers the non-session callers (background tasks, live notes,
|
||||
knowledge pipelines, scheduled agents):
|
||||
|
||||
```ts
|
||||
function runHeadlessTurn(input: {
|
||||
agent: RequestedAgent;
|
||||
context?: ConversationMessage[]; // inline; defaults to []
|
||||
input: UserMessage;
|
||||
maxModelCalls?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<TurnOutcome>;
|
||||
```
|
||||
|
||||
- `sessionId: null`, `autoPermission: true`, `humanAvailable: false`.
|
||||
- Creates the turn, advances to the first settled outcome, and returns it.
|
||||
- Standalone turns never appear in the index; callers keep their own turn
|
||||
IDs if they need history.
|
||||
|
||||
## 12. Deferred designs with committed shapes
|
||||
|
||||
These are not implemented in v1. Their shapes are recorded so v1 decisions
|
||||
stay compatible; each arrives as a session schema version bump.
|
||||
|
||||
### 12.1 Queued messages
|
||||
|
||||
```ts
|
||||
{ type: "message_queued", queueId, message, ts }
|
||||
{ type: "queued_message_replaced", queueId, message, ts } // edit
|
||||
{ type: "queued_message_removed", queueId, ts } // cancel
|
||||
// promotion: turn_appended gains consumedQueueIds?: string[]
|
||||
```
|
||||
|
||||
The reducer derives the pending queue by supersession. Promotion rules
|
||||
(collapse-into-one-turn vs FIFO, behavior after failed turns) are decided
|
||||
together with steering.
|
||||
|
||||
### 12.2 Session permission grants
|
||||
|
||||
```ts
|
||||
{ type: "permission_grant_added", grantId, toolId, ts }
|
||||
{ type: "permission_grant_removed", grantId, ts }
|
||||
```
|
||||
|
||||
The injected `IPermissionChecker` consults a session-keyed grants view
|
||||
before answering `required: true`. V1 grants would be blanket per-toolId;
|
||||
argument-pattern matchers are a separate, security-sensitive project.
|
||||
|
||||
### 12.3 Compaction (mechanism sketch)
|
||||
|
||||
Compaction requires zero turn-schema change. A session-level compaction
|
||||
event records `{ compactionId, summary, firstKeptTurnId }`; the next turn
|
||||
after a compaction uses **inline** context (summary message + kept
|
||||
transcript), which restarts the reference chain and bounds resolution depth
|
||||
by construction. Trigger policy and summarizer design are unspecified.
|
||||
|
||||
### 12.4 Steering
|
||||
|
||||
Rides the queue events with a different promotion rule, and additionally
|
||||
requires a turn-level `steer` external input (a turn schema bump) with an
|
||||
injection boundary after tool-batch completion. Recorded here so the session
|
||||
design does not foreclose it.
|
||||
|
||||
### 12.5 Persisted index cache
|
||||
|
||||
If the startup scan ever gets slow, a single cache file keyed by file
|
||||
mtimes can be added. It is a rebuildable cache, never a source of truth:
|
||||
missing, stale, or invalid means rebuild from session files.
|
||||
|
||||
### 12.6 Auto-titling
|
||||
|
||||
An LLM-generated title replacing the truncated-first-message default,
|
||||
appended as an ordinary `title_changed` event.
|
||||
|
||||
## 13. Required test scenarios
|
||||
|
||||
All tests use the in-memory/mocked turn runtime and repo fakes.
|
||||
|
||||
### 13.1 Reducer
|
||||
|
||||
- Valid event sequences reduce to expected state.
|
||||
- Every invariant violation throws: missing/duplicate `session_created`,
|
||||
mismatched sessionId, non-monotonic or gapped `sessionSeq`, duplicate
|
||||
turnIds, unknown type/version.
|
||||
- Title folding: default, explicit changes, last-wins.
|
||||
|
||||
### 13.2 Repository
|
||||
|
||||
- Date-partitioned paths, ID validation, create-if-absent.
|
||||
- Strict line validation on read; corrupt files rejected whole.
|
||||
- `listSessionIds` enumeration across partitions.
|
||||
- Deletion removes only the session file.
|
||||
|
||||
### 13.3 sendMessage
|
||||
|
||||
- First turn: inline `[]` context, `sessionSeq: 1`, default title appended.
|
||||
- Subsequent turns: context references the latest turn; seq increments.
|
||||
- Rejection with `TurnNotSettledError` when the latest turn is running,
|
||||
suspended, or idle — and success after it settles.
|
||||
- Continuation after failed and model-call-limit turns.
|
||||
- Concurrent sendMessage calls serialize under the session lock; exactly
|
||||
one wins, the other rejects.
|
||||
- Config lands on the turn; the session file stores only denormalized
|
||||
agent/model on `turn_appended`.
|
||||
|
||||
### 13.4 Ordering and crash simulation
|
||||
|
||||
- Simulated crash between `createTurn` and `turn_appended`: orphan turn
|
||||
file, session unchanged, retry produces a fresh turn.
|
||||
- `turn_appended` is present before the first advance begins.
|
||||
|
||||
### 13.5 External inputs
|
||||
|
||||
- Permission decision, ask-human answer, and async result each advance the
|
||||
correct turn with the correct input type.
|
||||
- Turn-runtime rejections (unknown call, terminal turn) pass through.
|
||||
- `sendMessage` never routes to ask-human.
|
||||
|
||||
### 13.6 Index
|
||||
|
||||
- Startup fold matches write-through state for the same history.
|
||||
- Latest-turn status derivation for every status value.
|
||||
- Corrupt session file yields an errored entry without aborting the scan.
|
||||
- Mutations publish `session-index-changed`; deletion removes the entry.
|
||||
|
||||
### 13.7 Event forwarding
|
||||
|
||||
- Forwarded events are tagged with sessionId and arrive in order.
|
||||
- Outcome settlement updates `latestTurnStatus`.
|
||||
|
||||
### 13.8 Headless
|
||||
|
||||
- Standalone turns: `sessionId: null`, auto permission, human unavailable,
|
||||
absent from the index.
|
||||
|
||||
## 14. Suggested module layout
|
||||
|
||||
```text
|
||||
apps/x/packages/shared/src/sessions.ts # event schemas, reducer, index types
|
||||
|
||||
apps/x/packages/core/src/sessions/
|
||||
sessions.ts # ISessions implementation
|
||||
repo.ts # ISessionRepo contract
|
||||
fs-repo.ts # filesystem implementation
|
||||
index.ts # in-memory index + startup scan
|
||||
headless.ts # runHeadlessTurn
|
||||
```
|
||||
|
||||
Awilix registration mirrors the turn runtime: singleton scope, PROXY
|
||||
constructor injection, no container resolution from inside the classes.
|
||||
|
||||
## 15. Integration sequence
|
||||
|
||||
The rollout is staged as commits on one branch (squash-merge acceptable);
|
||||
old and new stacks coexist briefly but never share state, and no data is
|
||||
migrated:
|
||||
|
||||
1. `@x/shared`: turn + session schemas and reducers.
|
||||
2. Turn runtime + fs turn repo (unit tests only, wired to nothing).
|
||||
3. Session layer + index (unit tests only).
|
||||
4. Bridges: agent resolver, context resolver, tool runner, permission
|
||||
checker/classifier — adapted from the `new-runtime` reference
|
||||
implementation where applicable.
|
||||
5. IPC (`sessions:*`) + renderer swap: Copilot chat UI moves to the
|
||||
sessions API.
|
||||
6. Headless callers move to `runHeadlessTurn`.
|
||||
7. Delete the old runs runtime.
|
||||
|
||||
## 16. Implementation acceptance criteria
|
||||
|
||||
The session layer is implementation-complete only when:
|
||||
|
||||
1. Session event schemas and `reduceSession` live in `@x/shared` and are
|
||||
consumed unchanged by core and renderer.
|
||||
2. Session files follow the partitioned append-only JSONL layout with
|
||||
strict validation.
|
||||
3. Turn-file-first write ordering is enforced; orphan turns are benign.
|
||||
4. `sendMessage` rejects non-terminal latest turns with a typed error; no
|
||||
implicit queueing, steering, or ask-human routing exists.
|
||||
5. Ask-human answers flow only through the dedicated endpoint.
|
||||
6. The index is write-through with a startup scan, no watcher, and no
|
||||
persisted cache; single-instance is enforced.
|
||||
7. Deletion removes only the session file.
|
||||
8. Headless callers run standalone turns and appear nowhere in the index.
|
||||
9. All required test scenarios pass with mocked dependencies.
|
||||
1887
apps/x/packages/core/docs/turn-runtime-design.md
Normal file
1887
apps/x/packages/core/docs/turn-runtime-design.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,9 @@
|
|||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -w -p tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"inspect-turn": "node dist/turns/inspect-cli.js",
|
||||
"inspect": "node dist/turns/inspect-cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
|
|
@ -28,6 +30,7 @@
|
|||
"@x/shared": "workspace:*",
|
||||
"ai": "^5.0.133",
|
||||
"awilix": "^12.0.5",
|
||||
"baileys": "7.0.0-rc13",
|
||||
"chokidar": "^4.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"cron-parser": "^5.5.0",
|
||||
|
|
@ -43,6 +46,7 @@
|
|||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"posthog-node": "^4.18.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"yaml": "^2.8.2",
|
||||
|
|
@ -54,6 +58,7 @@
|
|||
"@types/node": "^25.0.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ import { CronExpressionParser } from "cron-parser";
|
|||
import container from "../di/container.js";
|
||||
import { IAgentScheduleRepo } from "./repo.js";
|
||||
import { IAgentScheduleStateRepo } from "./state-repo.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
|
||||
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
|
||||
import { MessageEvent } from "@x/shared/dist/runs.js";
|
||||
import { createRun } from "../runs/runs.js";
|
||||
import { startHeadlessAgent } from "../agents/headless-app.js";
|
||||
import { withUseCase } from "../analytics/use_case.js";
|
||||
import z from "zod";
|
||||
|
||||
const DEFAULT_STARTING_MESSAGE = "go";
|
||||
|
|
@ -147,10 +144,7 @@ function shouldRunNow(
|
|||
async function runAgent(
|
||||
agentName: string,
|
||||
entry: z.infer<typeof AgentScheduleEntry>,
|
||||
stateRepo: IAgentScheduleStateRepo,
|
||||
runsRepo: IRunsRepo,
|
||||
agentRuntime: IAgentRuntime,
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator
|
||||
stateRepo: IAgentScheduleStateRepo
|
||||
): Promise<void> {
|
||||
console.log(`[AgentRunner] Starting agent: ${agentName}`);
|
||||
|
||||
|
|
@ -163,31 +157,24 @@ async function runAgent(
|
|||
});
|
||||
|
||||
try {
|
||||
// Create a new run via core (resolves agent + default model+provider).
|
||||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
useCase: 'copilot_chat',
|
||||
subUseCase: 'scheduled',
|
||||
});
|
||||
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
|
||||
|
||||
// Add the starting message as a user message
|
||||
// Start a standalone turn (resolves agent + default model/provider).
|
||||
// Fire-and-forget like the old trigger(): the schedule state machine
|
||||
// below runs on wall-clock; completion is observed only for logging.
|
||||
// The signal caps a hung turn at the same TIMEOUT_MS the state-based
|
||||
// watchdog uses.
|
||||
const startingMessage = entry.startingMessage ?? DEFAULT_STARTING_MESSAGE;
|
||||
const messageEvent: z.infer<typeof MessageEvent> = {
|
||||
runId: run.id,
|
||||
type: "message",
|
||||
messageId: await idGenerator.next(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: startingMessage,
|
||||
},
|
||||
subflow: [],
|
||||
};
|
||||
await runsRepo.appendEvents(run.id, [messageEvent]);
|
||||
console.log(`[AgentRunner] Sent starting message to agent ${agentName}: "${startingMessage}"`);
|
||||
|
||||
// Trigger the run
|
||||
await agentRuntime.trigger(run.id);
|
||||
const handle = await withUseCase(
|
||||
{ useCase: 'copilot_chat', subUseCase: 'scheduled' },
|
||||
() => startHeadlessAgent({
|
||||
agentId: agentName,
|
||||
message: startingMessage,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
console.log(`[AgentRunner] Started turn ${handle.turnId} for agent ${agentName}: "${startingMessage}"`);
|
||||
void handle.done
|
||||
.then((result) => console.log(`[AgentRunner] Turn ${handle.turnId} (${agentName}) settled: ${result.outcome.status}`))
|
||||
.catch((error) => console.error(`[AgentRunner] Turn ${handle.turnId} (${agentName}) errored:`, error instanceof Error ? error.message : error));
|
||||
|
||||
// Calculate next run time
|
||||
const nextRunAt = calculateNextRunAt(entry.schedule);
|
||||
|
|
@ -264,9 +251,6 @@ async function checkForTimeouts(
|
|||
async function pollAndRun(): Promise<void> {
|
||||
const scheduleRepo = container.resolve<IAgentScheduleRepo>("agentScheduleRepo");
|
||||
const stateRepo = container.resolve<IAgentScheduleStateRepo>("agentScheduleStateRepo");
|
||||
const runsRepo = container.resolve<IRunsRepo>("runsRepo");
|
||||
const agentRuntime = container.resolve<IAgentRuntime>("agentRuntime");
|
||||
const idGenerator = container.resolve<IMonotonicallyIncreasingIdGenerator>("idGenerator");
|
||||
|
||||
// Load config and state
|
||||
let config: z.infer<typeof AgentScheduleConfig>;
|
||||
|
|
@ -314,7 +298,7 @@ async function pollAndRun(): Promise<void> {
|
|||
|
||||
if (shouldRunNow(entry, agentState)) {
|
||||
// Run agent (don't await - let it run in background)
|
||||
runAgent(agentName, entry, stateRepo, runsRepo, agentRuntime, idGenerator).catch((error) => {
|
||||
runAgent(agentName, entry, stateRepo).catch((error) => {
|
||||
console.error(`[AgentRunner] Unhandled error in runAgent for ${agentName}:`, error);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue