diff --git a/.github/workflows/rowboat-build.yml b/.github/workflows/rowboat-build.yml deleted file mode 100644 index 270e6263..00000000 --- a/.github/workflows/rowboat-build.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Rowboat Build - -on: - pull_request: - -jobs: - build-rowboat-nextjs: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - cache-dependency-path: 'apps/rowboat/package-lock.json' - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - working-directory: apps/rowboat - - - name: Build Rowboat - run: npm run build - working-directory: apps/rowboat - - build-rowboatx: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - cache-dependency-path: 'apps/rowboat/package-lock.json' - node-version: '24' - cache: 'npm' - - - name: Install dependencies - run: npm ci - working-directory: apps/cli - - - name: Build Rowboat - run: npm run build - working-directory: apps/cli \ No newline at end of file diff --git a/.github/workflows/x-publish.yml b/.github/workflows/x-publish.yml deleted file mode 100644 index c411ab68..00000000 --- a/.github/workflows/x-publish.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Publish to npm - -on: workflow_dispatch - -permissions: - id-token: write # Required for OIDC - contents: read - -jobs: - publish: - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org/ - - - name: Update npm - run: npm install -g npm@latest - - - name: Install deps - run: npm ci - working-directory: apps/cli - - # optional: run tests - # - run: npm test - - - name: Build - run: npm run build - working-directory: apps/cli - - - name: Pack - run: npm pack - working-directory: apps/cli - - - name: Publish to npm - run: npm publish --access public - working-directory: apps/cli \ No newline at end of file diff --git a/.github/workflows/x-tests.yml b/.github/workflows/x-tests.yml index 9e8b1c7d..c08c4095 100644 --- a/.github/workflows/x-tests.yml +++ b/.github/workflows/x-tests.yml @@ -34,6 +34,12 @@ jobs: - name: Run apps/x Vitest suites run: npm test + # Typechecks with the dev tsconfigs, which include test files — + # the build tsconfigs exclude them and vitest strips types without + # checking, so this is the only gate that sees type errors in tests. + - name: Typecheck apps/x packages + run: npm run typecheck + apps-x-electron-package: name: apps/x Electron package smoke test runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 5e762a33..a5fe24c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ Long-form docs for specific features. Read the relevant file before making chang | 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` | +| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` | ## Common Tasks @@ -139,6 +139,7 @@ Long-form docs for specific features. Read the relevant file before making chang ### Verify compilation ```bash cd apps/x && npm run deps && npm run lint +cd apps/x && npm run typecheck # dev tsconfigs — the only gate that typechecks *.test.ts ``` ## Tech Stack diff --git a/README.md b/README.md index 934ab3b4..8d46398b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ + + rowboat-github-2 + +

Rowboat

@@ -26,14 +30,14 @@ -Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects. +Rowboat 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. Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)

-Rowboat demo video +Rowboat Apps to Code demo

@@ -51,10 +55,10 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do

Brain

-Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph. +Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph. -Screenshot 2026-06-24 at 11 22 52 PM +Brain graph screenshot @@ -63,35 +67,35 @@ Rowboat indexes email, meetings, slack and assistant conversations into a living The built-in email client sorts emails into important and everything else. Rowboat automatically drafts responses for important email using all the work context. -Email screenshot +Email screenshot

Background agents

-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. +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. -Background agents screenshot +Background agents screenshot

Built-in Browser

-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. +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. -Browser screenshot +Browser screenshot

Meeting Notes

-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. +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. -Meeting notes screenshot +Meeting notes screenshot @@ -100,25 +104,25 @@ A local meeting note-taker that taps into mic & speaker, produces live transcrip 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. -Code mode screenshot +Code mode screenshot

Apps

-You can bulild your own work surfaces inside Rowboat — they get acess to all the tools and integrations, and you can share them with other people. +You can bulild your own work surfaces inside Rowboat — they get acess to all the tools and integrations, and you can share them with other people. -Apps screenshot +Apps screenshot

Integrations

-Includes one-click integrations to most popular products. +Includes one-click integrations to most popular products. -Integrations screenshot +Integrations screenshot diff --git a/apps/cli/todo.md b/apps/cli/todo.md index 9c97afd3..3ca4d45c 100644 --- a/apps/cli/todo.md +++ b/apps/cli/todo.md @@ -12,4 +12,4 @@ o file logging rowboat agent --- -- create agent \ No newline at end of file +- create agent diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 8442b158..3178f741 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -55,6 +55,6 @@ "chatgpt", "claude" ] - } + } } - \ No newline at end of file + diff --git a/apps/docs/docs/development/contribution-guide.mdx b/apps/docs/docs/development/contribution-guide.mdx index 1089d7e0..8fbf2a1d 100644 --- a/apps/docs/docs/development/contribution-guide.mdx +++ b/apps/docs/docs/development/contribution-guide.mdx @@ -52,4 +52,4 @@ Rowboat is open-source and we welcome contributions of all kinds — bug reports ## Getting Help -If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked. \ No newline at end of file +If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked. diff --git a/apps/docs/docs/development/roadmap.mdx b/apps/docs/docs/development/roadmap.mdx index 6248e567..8202b2b2 100644 --- a/apps/docs/docs/development/roadmap.mdx +++ b/apps/docs/docs/development/roadmap.mdx @@ -4,4 +4,4 @@ icon: "road" # Roadmap -Explore the future development plans and upcoming features for Rowboat. \ No newline at end of file +Explore the future development plans and upcoming features for Rowboat. diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 919511ca..a2bfa3ae 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -41,9 +41,9 @@ Every `llm_usage` emit point in the codebase: | `use_case` | `sub_use_case` | `agent_name`? | Where | File:line | |---|---|---|---|---| -| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) | +| `copilot_chat` | (none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | `packages/core/src/runtime/turns/bridges/real-usage-reporter.ts` (`reportModelUsage`); legacy runs (code-mode carve-out) still emit from `packages/core/src/runtime/legacy/engine.ts` (`streamLlm` finish-step) | | `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` | -| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` | +| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/runtime/tools/domains/parsing.ts:179` | | `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` | | `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) | | `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site | @@ -53,7 +53,6 @@ Every `llm_usage` emit point in the codebase: | `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) | | `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) | | `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) | -| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) | | `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | | `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | | `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | diff --git a/apps/x/GRANOLA_PARITY.md b/apps/x/GRANOLA_PARITY.md new file mode 100644 index 00000000..53af3bd9 --- /dev/null +++ b/apps/x/GRANOLA_PARITY.md @@ -0,0 +1,153 @@ +# Granola Parity — Research & Gap Analysis + +Goal: make Rowboat's meeting feature work exactly like Granola (granola.ai). We copy Granola's behavior — no new invention. This doc is the source of truth: how Granola works, what Rowboat has today (with file:line pointers), the gaps, and the parity plan. + +Research basis: Granola's official docs (docs.granola.ai), granola.ai blog/security/jobs pages, third-party reviews and reverse-engineering writeups, plus a full audit of this codebase. Claims that are inferred (not directly documented by Granola) are marked **[inference]**. + +--- + +## Part 1 — How Granola works + +Granola is an Electron app (confirmed by their own job postings: "Granola is an Electron app with deep OS integrations", React/TypeScript UI + native OS helpers). No bot ever joins the call — it captures audio locally on the device, so nothing is visible to other meeting participants. + +### 1.1 Always-running app (launch at login) + +- The product model is an **always-running, menu-bar-resident app**. Granola must be running for detection/notifications to work ("You must have Granola open... for transcription to run" — their troubleshooting docs). +- A literal "open at login" toggle is **not publicly documented**. **[inference]** A clone should register as a login item (Electron `app.setLoginItemSettings`), default on, with a setting to turn it off — the entire detection value prop collapses if the app isn't running. +- The app keeps running when the window closes (menu bar presence remains). + +### 1.2 Menu bar icon + +- Granola "sits in your Mac's menu bar". Documented affordance: **click the menu bar icon to start recording / open the app**. +- Recording status is NOT primarily shown in the menu bar. The documented indicators are: + - Inside the note: "green dancing bars" at the bottom while capturing. + - When another app has focus: a **floating always-on-top "live meeting" pill** on the right side of the screen. +- Exact dropdown contents (upcoming meetings list, icon state change while recording) are not documented. The "Coming up" list lives on the home screen, not the tray. + +### 1.3 Meeting detection — two signals, never auto-record + +Granola detects meetings via **calendar events + microphone-in-use detection**, and it **never records without a user action** ("Granola only starts transcribing when you open a note for that meeting"). + +**Signal A — Calendar:** +- Google / Microsoft calendar sync. Pre-creates an auto-titled note per event with 2+ attendees; filters declined events, OOO, Focus Time. +- **In-app popup (Granola-drawn, not macOS Notification Center) 1 minute before** any scheduled call with 2+ attendees. One click on it **opens the meeting URL AND starts transcription** at once. +- **Armed auto-start:** if you have the upcoming meeting's note open before it starts, recording starts automatically at the scheduled time. + +**Signal B — Mic-in-use (ad-hoc calls, incl. browser meetings):** +- "Granola notices when your microphone is in use and offers to start taking notes." +- It's app-aware: notification title is "**Huddle detected**" (Slack), "**Call detected**" (FaceTime/WhatsApp), "**Meeting detected**" (anything else, including browsers). Buttons: "Take Notes". Ad-hoc popups have a dashed left border; calendar ones a solid colored bar. +- If the ad-hoc call starts within **15 minutes** of a scheduled event, the popup adopts that event's name (merged). +- **[inference]** Mechanism: poll CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` on input devices for "mic in use", plus enumerate running processes against a known meeting-app list to get the app-specific title. There's no public macOS API for "process X is using the mic", so it's a heuristic combo. Browser meetings degrade to the generic "Meeting detected" title; calendar linkage supplies identity. +- Notifications are configurable in Settings (off entirely, or per-application). +- Related preference: "**Reposition Granola for meetings**" — window auto-repositions/resizes alongside the call when a meeting starts (toggleable, default on). + +### 1.4 Audio capture + +- Capture = **default microphone + system audio output**, at the OS level. Works with any app or browser (Chrome/Safari/Firefox/Edge) with no extension. +- Cannot isolate per-app audio — it's the combined system stream; uses the OS default sound devices. +- The two streams stay separate through transcription → transcript UI shows **grey bubbles (left) = system audio ("Them"), green bubbles (right) = your mic ("Me")**. No true live diarization on desktop — just Me/Them. +- **macOS permissions: exactly two** — Microphone, and Screen & System Audio Recording (macOS bundles system-audio capture under screen recording). No Accessibility permission. +- **[inference]** System audio mechanism: ScreenCaptureKit audio loopback (macOS 13+ baseline, they require 13+ / recommend 14.2+), possibly CoreAudio process taps on 14.4+. No virtual audio driver (setup has no driver install step). +- **Audio is never stored** — streamed to the ASR provider in real time and discarded. + +### 1.5 Transcription + +- Cloud, real-time streaming ASR. Subprocessors named on their security page: **Deepgram and AssemblyAI** for ASR; **OpenAI and Anthropic** for note enhancement. +- Live transcript accrues during the call, hidden behind a waveform-icon toggle in the note. + +### 1.6 Meeting end & post-meeting flow + +- **Auto-stop conditions** (documented): (a) call-end heuristic — transcript inactivity + whether the call software is still in use + scheduled end time for calendar-linked meetings; (b) **15 minutes of silence**; (c) computer sleeps; (d) manual stop. Note: "on macOS, meeting auto-end detection requires admin rights" — without it, manual stop only. +- **On stop, enhancement runs automatically** — no click needed. Enhanced notes are ready in seconds (~30s max reported). +- A "**notes ready**" notification fires when enhancement completes — that's the re-entry point back into the app if you switched away. The note itself was already open (recording ran inside it), so the enhanced version replaces the raw view in place. There's no documented "force-focus the window" behavior; the notification does the redirecting. +- Extras: auto-drafted follow-up emails (toggleable), pre-meeting briefs. + +### 1.7 Notes UI + +**During the meeting:** a plain notepad — the user types rough bullets; transcription runs invisibly. Green dancing bars at the bottom = capturing. Waveform button (next to the per-note "Ask anything" chat bar) opens the live transcript side-by-side. Mid-meeting you can ask the chat to catch you up. + +**After the meeting (enhanced notes):** +- Enhancement merges exactly three inputs: **transcript + your raw notes + calendar metadata**, through a template. +- Signature affordance: **your words render black, AI-generated text grey**. +- Per-line provenance: magnifying-glass icon on a line reveals where it came from in the transcript. +- Templates: built-ins (1:1, standup, sales discovery) + custom; re-enhance with a different template (✨), regenerate (🔁), edit raw notes and re-enhance. +- Chat: per-note "Ask anything" bar, global chat (⌘J), cross-meeting chat over folders; edit-by-asking. +- Home screen: "**Coming up**" strip (next ~5 meetings) + reverse-chronological past notes list; folders; recurring meetings grouped by recurring-event ID + title; shareable links. + +--- + +## Part 2 — What Rowboat has today (audit of main) + +Two separate feature families exist in `apps/x`; only the second is Granola-adjacent: +- **"Calls" (video mode)** — live voice/video chat *with the Rowboat AI* (`VIDEO_MODE.md`). Not meeting capture. +- **"Meetings"** — real meeting capture → transcript → AI notes. Working pipeline, but **calendar-triggered and manual-click only**. + +### What EXISTS (and is solid) + +| Area | Status | Where | +|---|---|---| +| Mic + system-audio capture, 2-channel | ✅ | `apps/renderer/src/hooks/useMeetingTranscription.ts:282-299` — mic `getUserMedia` (ch 0) + `getDisplayMedia({audio})` loopback (ch 1), merged to 16 kHz PCM (`:424-485`); loopback auto-approved in `apps/main/src/main.ts:216-226` | +| Realtime ASR | ✅ | Deepgram realtime WS, `nova-3`, multichannel + diarize (`useMeetingTranscription.ts:9-21`); proxy or raw key (`:253-280`) | +| Speaker labels | ✅ | ch 0 → "You", ch 1 → diarized `Speaker N` (`:335-347`) | +| Transcript storage | ✅ | Markdown + frontmatter + fenced transcript block, `knowledge/Meetings/rowboat//.md` (`:93-136`, `:487-510`) | +| AI meeting notes on stop | ✅ | `packages/core/src/knowledge/summarize_meeting.ts` — LLM summary, attendee-name resolution from calendar; orchestrated in `App.tsx:5601-5661`; notes prepended above transcript | +| Auto-stop heuristics | ✅ (partial) | silence RMS backstop, calendar-end gating, system-track ended/muted — `useMeetingTranscription.ts:378-549` | +| Calendar sync | ✅ | Google OAuth via `googleapis`, `packages/core/src/knowledge/sync_calendar.ts`, per-event JSON in `~/.rowboat/calendar_sync/` | +| Pre-meeting notification | ✅ (system toast) | `packages/core/src/knowledge/notify_calendar_meetings.ts` — polls every 30s, notifies ~1 min before, deep-links `rowboat://action?type=join-and-take-meeting-notes` | +| Meetings screen | ✅ | `apps/renderer/src/components/meetings-view.tsx` — "Coming up" (+Join / Take-notes buttons, inline prep) + past-notes table | +| Transcript rendering | ✅ | `apps/renderer/src/extensions/transcript-block.tsx` (TipTap, colored speakers, collapsible) | +| Meeting prep briefs | ✅ | `meeting_prep_scheduler.ts`, `meeting_prep_brief.ts` (Granola has this too) | +| Permissions flows | ✅ | mic `ipc.ts:2090-2106`; screen recording check/open-settings `ipc.ts:2003-2018`; Info.plist strings `forge.config.cjs:201-204`; entitlements OK | + +### What is MISSING (the entire "ambient" layer) + +| # | Gap | Detail | +|---|---|---| +| 1 | **Launch at login** | Zero `setLoginItemSettings` / auto-launch code anywhere. | +| 2 | **Menu bar (tray) icon** | Zero `Tray` usage. No background presence: on macOS closing the window leaves only the Dock; no way to start recording without the full window. | +| 3 | **Meeting detection** | No mic-in-use detection, no process detection (Zoom/Teams/Slack/FaceTime), no browser awareness. Only calendar (time-based). Ad-hoc calls are invisible. | +| 4 | **Auto/one-click start UX** | Capture starts only via explicit button click or clicking the calendar toast. No "Meeting detected — Take notes?" popup, no armed auto-start when a note is open at meeting start time. | +| 5 | **Headless capture** | Capture depends on the renderer window holding a live `getDisplayMedia` stream. No native audio helper → can't capture while the app is "in the background" the way Granola does. | +| 6 | **Meeting-end redirect** | On stop, the note refreshes in place, but there's **no "notes ready" notification** and no bring-the-user-back moment if they're in another app. | +| 7 | **Notes UI polish (Granola signatures)** | No black-vs-grey authorship distinction (we replace, they merge raw notes + transcript), no during-meeting notepad-first flow (we show the transcript note), no per-line provenance, no templates/re-enhance, no floating "recording" pill. | + +### Honest assessment + +Rowboat has built the *second half* of Granola well — what happens once you're recording, and after the meeting ends. It has essentially none of the *first half* — noticing a meeting is happening and quietly being there without the user opening the app. That first half is exactly items 1–5 above, and it's where all the work is. + +--- + +## Part 3 — Parity plan (copy Granola, no new stuff) + +Ordered so each phase is shippable and testable on its own. + +### Phase 1 — Resident app: login item + tray +- `app.setLoginItemSettings({ openAtLogin: true })`, default on, toggle in Settings. When launched at login: no window, tray only **[inference — Granola undocumented, but implied]**. +- Electron `Tray` with template icon; menu: "Start recording", "Open Rowboat", recording status line, Quit. Keep app alive on window close (already macOS default; add tray so it's reachable). +- Acceptance: reboot → icon in menu bar, no window; click tray → start an ad-hoc meeting note. + +### Phase 2 — Meeting detection + "Take notes?" popup +- Native signal (small Swift helper or node addon, polled from main): mic-in-use via CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` + running-process match against a known list (zoom.us, Teams, Slack, FaceTime, browsers…). +- Granola-style app-drawn popup (small always-on-top BrowserWindow, like the existing video popout): "Huddle detected / Call detected / Meeting detected — [Take Notes]". Merge with a calendar event if within 15 min. Never auto-record. +- Upgrade the existing 1-min-before calendar toast to the same popup style; one click = open meeting URL + start capture (deep-link plumbing already exists in `deeplink.ts`). +- Armed auto-start: meeting note open before start time → auto-start at start time. +- Per-app notification settings. +- Acceptance: start a Meet call in Chrome with no calendar event → popup appears; click → recording. + +### Phase 3 — Meeting end → notes ready redirect +- Keep existing auto-stop heuristics; add the missing "call software no longer active" signal from the Phase-2 process watcher; keep 15-min silence backstop (ours is stricter — align to Granola's 15 min). +- On summary completion: fire a **"Your meeting notes are ready" notification**; clicking focuses the app on the note (deep link exists). This is the "redirect when we cut the call" the feature needs. +- Acceptance: leave the call → recording stops on its own → notification within ~30s → click lands on finished notes. + +### Phase 4 — Notes UI parity +- During meeting: notepad-first note (user types; transcript behind a waveform toggle); green capture indicator in-note; floating "live meeting" pill when app unfocused (reuse the video-popout window machinery). +- After meeting: enhancement merges **raw notes + transcript + calendar event** (today we only use the transcript); render user text black / AI text grey; ✨ re-enhance + 🔁 regenerate; keep transcript below as today. +- Home: Meetings view already ≈ Granola's home (Coming up + past list) — minor polish only. + +### Phase 5 (only if needed for true parity) — Headless capture +- Native mic + ScreenCaptureKit system-audio capture in main/helper process so recording doesn't require the renderer window. Biggest lift; Phases 1–4 deliver the Granola experience with the window opening on record start, which is acceptable Granola-like behavior (their note opens on start too). + +### Key implementation notes +- Permissions stay exactly two (mic + screen-recording) — we already handle both. +- Deepgram nova-3 multichannel already matches Granola's Me/Them model — no ASR change needed. +- Reuse: popup ← video popout window pattern; deep links ← `deeplink.ts`; detection loop ← same main-process init pattern as `notify_calendar_meetings.ts`. diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index d8a157d7..85f16786 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -326,7 +326,7 @@ Three branches by `trigger`: ### 5. Live Note skill (Copilot-facing) - **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills. -- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant. +- **File**: `packages/core/src/runtime/assembly/skills/live-note/skill.ts`. Exported `skill` constant. - **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` is interpolated into the "Canonical Schema" section. Edits to `LiveNoteSchema` propagate automatically. - **Output**: markdown, injected into the Copilot system prompt when `loadSkill('live-note')` fires. - **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`. @@ -334,7 +334,7 @@ Three branches by `trigger`: ### 6. Copilot trigger paragraph - **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded. -- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live Notes" paragraph). +- **File**: `packages/core/src/runtime/assembly/copilot/instructions.ts` (look for the "Live Notes" paragraph). - **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…"). - **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up. - **Anti-signals (do NOT make live)**: definitional questions, one-off lookups, manual document editing. @@ -343,7 +343,7 @@ Three branches by `trigger`: ### 7. `run-live-note-agent` tool — `context` parameter description - **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. -- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-live-note-agent` tool definition). +- **File**: `packages/core/src/runtime/tools/catalog.ts` (the `run-live-note-agent` tool definition). - **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), optional `context`. - **Output**: flows into `runLiveNoteAgent(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message. - **Key use case**: backfill a newly-made-live note so its body isn't empty on day 1. @@ -394,10 +394,10 @@ Conventions: | Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | -| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` | -| Skill registration | `packages/core/src/application/assistant/skills/index.ts` | -| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | -| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` | +| Copilot skill | `packages/core/src/runtime/assembly/skills/live-note/skill.ts` | +| Skill registration | `packages/core/src/runtime/assembly/skills/index.ts` | +| Copilot trigger paragraph | `packages/core/src/runtime/assembly/copilot/instructions.ts` | +| `run-live-note-agent` builtin tool | `packages/core/src/runtime/tools/catalog.ts` | | Editor toolbar (Radio button → panel) | `apps/renderer/src/components/editor-toolbar.tsx` | | Live Note panel (single-view editor) | `apps/renderer/src/components/live-note-sidebar.tsx` | | Status hook (`useLiveNoteAgentStatus`) | `apps/renderer/src/hooks/use-live-note-agent-status.ts` | diff --git a/apps/x/VIDEO_MODE.md b/apps/x/VIDEO_MODE.md index 653521cb..999a7f56 100644 --- a/apps/x/VIDEO_MODE.md +++ b/apps/x/VIDEO_MODE.md @@ -108,7 +108,7 @@ is live) to the outgoing message as `UserImagePart`s and sets (`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): +- `packages/core/src/runtime/assembly/message-encoding.ts` `convertFromMessages`: emits a context line (frame counts + time span), then labeled groups — a `"Webcam frames (oldest to newest):"` text part before camera images and a `"Screen-share frames (oldest to newest):"` text part before screen @@ -176,11 +176,11 @@ Push-to-talk is disabled while a call owns the mic. | 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` | +| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/runtime/assembly/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `runtime/assembly/compose-instructions.ts`) | +| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) | +| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`runtime/assembly/skills/app-navigation/skill.ts`) | +| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/runtime/assembly/message-encoding.ts` (`convertFromMessages`) | +| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/runtime/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` | Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are reused untouched — calls set `voiceInput` per utterance and force diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 548e037c..696084f3 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -91,6 +91,26 @@ if (!fs.existsSync(stagedBinary)) { } console.log('✅ node-pty staged in .package/node_modules'); +// Compile the mic-monitor helper (ambient meeting detection) on macOS. +// Best-effort: without swiftc — or on other platforms — the app still works, +// ad-hoc meeting detection just stays off (main checks the binary exists). +if (process.platform === 'darwin') { + const swiftSrc = path.join(here, 'native', 'mic-monitor.swift'); + const helperOut = path.join(here, '.package', 'dist', 'mic-monitor'); + const upToDate = fs.existsSync(helperOut) && + fs.statSync(helperOut).mtimeMs >= fs.statSync(swiftSrc).mtimeMs; + if (upToDate) { + console.log('✅ mic-monitor helper up to date'); + } else { + try { + execSync(`swiftc -O "${swiftSrc}" -o "${helperOut}"`, { stdio: 'inherit' }); + console.log('✅ mic-monitor helper compiled'); + } catch { + console.warn('⚠️ mic-monitor helper not built (swiftc unavailable?) — meeting detection disabled'); + } + } +} + // Bundle the vendored agent-slack CLI into a single self-contained script next // to main.cjs. It runs as a child process (process.execPath with // ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't diff --git a/apps/x/apps/main/native/mic-monitor.swift b/apps/x/apps/main/native/mic-monitor.swift new file mode 100644 index 00000000..239de7ee --- /dev/null +++ b/apps/x/apps/main/native/mic-monitor.swift @@ -0,0 +1,180 @@ +// mic-monitor: prints a JSON line whenever the set of processes using the +// microphone changes. +// +// This is the ambient meeting-detection signal (Granola-style): when another +// app (Zoom, Meet in a browser, Slack huddle, FaceTime…) opens the +// microphone, we report it. No audio is captured, so this requires no +// microphone permission (TCC) — it's device state, not content. +// +// Two signals, best available wins: +// - macOS 14.4+: per-process audio objects (kAudioHardwarePropertyProcessObjectList +// + kAudioProcessPropertyIsRunningInput) give the processes that own the +// mic. Each owner is resolved to its bundle ID (CoreAudio) and executable +// path (libproc) HERE, natively — the consumer must not need to shell out +// (child-process spawns from Electron's main process fail with EBADF +// while capture is active). +// - Fallback: kAudioDevicePropertyDeviceIsRunningSomewhere on the default +// input device — mic in use by *someone*, no attribution. +// +// Protocol: one JSON object per line on stdout, emitted on every state +// change (and once at startup): +// {"micInUse":true,"owners":[{"pid":123,"bundleId":"com.google.Chrome.helper","path":"/Applications/..."}]} +// ("owners" is empty when attribution is unavailable.) +// The process exits when stdin closes, so it can never outlive the app. +// +// Compiled by apps/main/bundle.mjs (best-effort, macOS only) to +// .package/dist/mic-monitor. + +import Foundation +import CoreAudio +import Darwin + +func defaultInputDeviceID() -> AudioDeviceID? { + var deviceID = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &deviceID) + guard status == noErr, deviceID != kAudioObjectUnknown else { return nil } + return deviceID +} + +func isRunningSomewhere(_ device: AudioDeviceID) -> Bool { + var running = UInt32(0) + var size = UInt32(MemoryLayout.size) + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let status = AudioObjectGetPropertyData(device, &addr, 0, nil, &size, &running) + return status == noErr && running != 0 +} + +func audioProcessObjectIDs() -> [AudioObjectID] { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyProcessObjectList, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size = UInt32(0) + let sysID = AudioObjectID(kAudioObjectSystemObject) + guard AudioObjectGetPropertyDataSize(sysID, &addr, 0, nil, &size) == noErr, size > 0 else { + return [] + } + var ids = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData(sysID, &addr, 0, nil, &size, &ids) == noErr else { return [] } + return ids +} + +func processIsRunningInput(_ object: AudioObjectID) -> Bool { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioProcessPropertyIsRunningInput, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var value = UInt32(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) + return status == noErr && value != 0 +} + +func processPID(_ object: AudioObjectID) -> Int32? { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioProcessPropertyPID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var pid = pid_t(-1) + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &pid) == noErr, pid >= 0 else { + return nil + } + return Int32(pid) +} + +func processBundleID(_ object: AudioObjectID) -> String? { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioProcessPropertyBundleID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var value: Unmanaged? = nil + var size = UInt32(MemoryLayout?>.size) + guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr, + let cf = value else { return nil } + let str = cf.takeRetainedValue() as String + return str.isEmpty ? nil : str +} + +func processPath(_ pid: Int32) -> String? { + var buffer = [CChar](repeating: 0, count: 4096) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + return String(cString: buffer) +} + +struct MicOwner: Equatable { + let pid: Int32 + let bundleId: String + let path: String +} + +/// Processes currently capturing from any input device (macOS 14.4+; +/// returns [] where unsupported and the device-level fallback takes over). +func micOwners() -> [MicOwner] { + var owners: [MicOwner] = [] + for object in audioProcessObjectIDs() where processIsRunningInput(object) { + guard let pid = processPID(object) else { continue } + owners.append(MicOwner( + pid: pid, + bundleId: processBundleID(object) ?? "", + path: processPath(pid) ?? "")) + } + return owners.sorted { $0.pid < $1.pid } +} + +func jsonEscape(_ s: String) -> String { + var out = "" + for ch in s.unicodeScalars { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if ch.value < 0x20 { + out += String(format: "\\u%04x", ch.value) + } else { + out.unicodeScalars.append(ch) + } + } + } + return out +} + +// Exit when the parent closes our stdin (app quit/crash) — never orphan. +Thread { + while readLine(strippingNewline: false) != nil {} + exit(0) +}.start() + +setbuf(stdout, nil) + +var lastInUse: Bool? = nil +var lastOwners: [MicOwner] = [] +while true { + let owners = micOwners() + // Re-resolve the default device every poll: the user can switch input + // devices (AirPods in/out) mid-session. + let deviceInUse = defaultInputDeviceID().map(isRunningSomewhere) ?? false + let inUse = deviceInUse || !owners.isEmpty + if inUse != lastInUse || owners != lastOwners { + lastInUse = inUse + lastOwners = owners + let ownerJson = owners.map { o in + "{\"pid\":\(o.pid),\"bundleId\":\"\(jsonEscape(o.bundleId))\",\"path\":\"\(jsonEscape(o.path))\"}" + }.joined(separator: ",") + print("{\"micInUse\":\(inUse),\"owners\":[\(ownerJson)]}") + } + Thread.sleep(forTimeInterval: 1.0) +} diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 5119b6ba..256fe365 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -2,7 +2,7 @@ import { shell, BrowserWindow } from 'electron'; import { createAuthServer } from './auth-server.js'; import * as composioClient from '@x/core/dist/composio/client.js'; import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; -import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/runtime/assembly/copilot/instructions.js'; import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js'; import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 7dff8d1b..8df7e6d2 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -12,8 +12,8 @@ import { watcher as watcherCore, workspace } from '@x/core'; import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; -import * as runsCore from '@x/core/dist/runs/runs.js'; -import { bus } from '@x/core/dist/runs/bus.js'; +import * as runsCore from '@x/core/dist/runtime/legacy/runs.js'; +import { bus } from '@x/core/dist/runtime/legacy/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; @@ -29,7 +29,9 @@ let caffeinateBlockerId: number | null = null; 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 { isDurableTurnEvent } from '@x/shared/dist/turns.js'; +import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js'; +import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.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'; @@ -53,7 +55,7 @@ import * as codeGit from '@x/core/dist/code-mode/git/service.js'; import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js'; import type { CodeSession } from '@x/shared/dist/code-sessions.js'; -import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/runtime/assembly/copilot/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'; @@ -64,6 +66,20 @@ import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js'; +import { saveAppSettings } from '@x/core/dist/config/app_settings.js'; +import { setSelfCaptureActive } from '@x/core/dist/meetings/detector.js'; +import { notifyIfEnabled } from '@x/core/dist/application/notification/notifier.js'; +import { consumePendingToggleMeetingNotes, setTrayRecordingState } from './tray.js'; +import { closeMeetingPopup, getMeetingPopupPayload, handleMeetingPopupAction } from './meeting-popup.js'; + +// Ambient meeting detection must ignore Rowboat's own mic use: meeting +// capture and assistant voice/video calls both hold the mic. Either being +// active suppresses "Meeting detected" prompts. +let meetingRecordingActive = false; +let voiceCallActive = false; +function updateSelfCaptureState() { + setSelfCaptureActive(meetingRecordingActive || voiceCallActive); +} import * as composioHandler from './composio-handler.js'; import * as appsIndexer from '@x/core/dist/apps/indexer.js'; import * as appsServer from '@x/core/dist/apps/server.js'; @@ -79,6 +95,9 @@ import * as appsPublisher from '@x/core/dist/apps/publisher.js'; // D18 install previews awaiting confirmation, keyed by app name. const appInstallPreviews = new Map>>(); +// Last-seen app-set fingerprint; a change invalidates the copilot +// instructions cache (they embed the installed-apps list). +let lastAppsFingerprint: string | null = null; import { consumePendingDeepLink } from './deeplink.js'; import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; @@ -95,7 +114,9 @@ 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, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, archiveCategoryThreads, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance, setThreadCategory } from '@x/core/dist/knowledge/sync_gmail.js'; +import { loadEmailInstructions, saveEmailInstructions } from '@x/core/dist/knowledge/email_instructions.js'; +import { getEmailLabels, syncCustomLabelsFromInstructions } from '@x/core/dist/knowledge/email_labels.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'; @@ -488,24 +509,14 @@ let debounceTimer: ReturnType | null = null; * Emit knowledge commit event to all renderer windows */ function emitKnowledgeCommitEvent(): void { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('knowledge:didCommit', {}); - } - } + broadcastToWindows('knowledge:didCommit', {}); } /** * Emit workspace change event to all renderer windows */ function emitWorkspaceChangeEvent(event: z.infer): void { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('workspace:didChange', event); - } - } + broadcastToWindows('workspace:didChange', event); } /** @@ -590,11 +601,12 @@ function handleWorkspaceChange(event: z.infer): void { +// The one renderer fan-out: send a payload to every live window on a channel. +// All broadcast feeds (runs, services, sessions, turns, code runs, agent +// status) go through here. +function broadcastToWindows(channel: string, payload: unknown): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { if (!win.isDestroyed() && win.webContents) { - win.webContents.send('runs:events', event); + win.webContents.send(channel, payload); } } } +function emitRunEvent(event: z.infer): void { + broadcastToWindows('runs:events', event); +} + function emitServiceEvent(event: z.infer): void { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('services:events', event); - } - } + broadcastToWindows('services:events', event); } 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) { - win.webContents.send('oauth:didConnect', event); - } - } + broadcastToWindows('oauth:didConnect', event); } async function requireCodeSession(sessionId: string): Promise { @@ -669,12 +678,7 @@ export async function startCodeSessionStatusWatcher(): Promise { const tracker = container.resolve('codeSessionStatusTracker'); await tracker.start(); codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('codeSession:status', { sessionId, status }); - } - } + broadcastToWindows('codeSession:status', { sessionId, status }); }); } @@ -690,12 +694,7 @@ export async function startRunsWatcher(): Promise { // 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); - } - } + broadcastToWindows('sessions:events', event); } // Mobile channels: status changes (QR pairing, connect/disconnect) → renderer. @@ -703,12 +702,7 @@ 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); - } - } + broadcastToWindows('channels:status', status); }); } @@ -721,6 +715,55 @@ export function startSessionsWatcher(): void { sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event)); } +// Turn event spine → renderer windows: durable events of every turn the +// runtime executes (session chat, headless background/knowledge runners, +// spawned sub-agents), tagged with sessionId and the event's file offset so +// consumers can join a live turn against a sessions:getTurn snapshot without +// gaps or duplicates. Durable events are broadcast to every window; +// text/reasoning deltas are high-volume and ephemeral, so they are sent only +// to windows that subscribed to that turn via turns:subscribe. +const turnDeltaSubs = new Map>(); + +export function subscribeTurnDeltas(sender: Electron.WebContents, turnId: string): void { + let turnIds = turnDeltaSubs.get(sender); + if (!turnIds) { + turnIds = new Set(); + turnDeltaSubs.set(sender, turnIds); + sender.once('destroyed', () => turnDeltaSubs.delete(sender)); + } + turnIds.add(turnId); +} + +export function unsubscribeTurnDeltas(sender: Electron.WebContents, turnId: string): void { + const turnIds = turnDeltaSubs.get(sender); + if (!turnIds) { + return; + } + turnIds.delete(turnId); + if (turnIds.size === 0) { + turnDeltaSubs.delete(sender); + } +} + +let turnEventsWatcher: (() => void) | null = null; +export function startTurnEventsWatcher(): void { + if (turnEventsWatcher) { + return; + } + const hub = container.resolve('turnEventBus'); + turnEventsWatcher = hub.subscribeAll((event) => { + if (isDurableTurnEvent(event.event)) { + broadcastToWindows('turns:events', event); + return; + } + for (const [sender, turnIds] of turnDeltaSubs) { + if (turnIds.has(event.turnId) && !sender.isDestroyed()) { + sender.send('turns:events', event); + } + } + }); +} + // Ephemeral code-run stream: CodeRunFeed → all renderer windows. A direct // tool→renderer side-channel that bypasses the turn runtime; the durable // record is the settle-time code-run-events-batch tool progress. @@ -731,12 +774,7 @@ export function startCodeRunFeedWatcher(): void { } const feed = container.resolve('codeRunFeed'); codeRunFeedWatcher = feed.subscribe((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('codeRun:events', event); - } - } + broadcastToWindows('codeRun:events', event); }); } @@ -767,12 +805,7 @@ let liveNoteAgentWatcher: (() => void) | null = null; export function startLiveNoteAgentWatcher(): void { if (liveNoteAgentWatcher) return; liveNoteAgentWatcher = liveNoteBus.subscribe((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('live-note-agent:events', event); - } - } + broadcastToWindows('live-note-agent:events', event); }); } @@ -780,12 +813,7 @@ let backgroundTaskAgentWatcher: (() => void) | null = null; export function startBackgroundTaskAgentWatcher(): void { if (backgroundTaskAgentWatcher) return; backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('bg-task-agent:events', event); - } - } + broadcastToWindows('bg-task-agent:events', event); }); } @@ -854,6 +882,59 @@ export function setupIpcHandlers() { }, 'updater:quitAndInstall': async () => { quitAndInstallUpdate(); + 'app:consumePendingTrayCommand': async () => { + return { toggleMeetingNotes: consumePendingToggleMeetingNotes() }; + }, + 'app:getLoginItemSettings': async () => { + // Dev builds never register a login item (it would point at the dev + // Electron binary), so report off. + if (!app.isPackaged) return { openAtLogin: false }; + return { openAtLogin: app.getLoginItemSettings().openAtLogin }; + }, + 'app:setLoginItemSettings': async (_event, args) => { + if (app.isPackaged) { + app.setLoginItemSettings({ + openAtLogin: args.openAtLogin, + ...(process.platform === 'win32' ? { args: ['--hidden'] } : {}), + }); + // The user has expressed an explicit choice — never re-apply the + // first-run default over it. + saveAppSettings({ loginItemRegistered: true }); + } + return { success: true as const }; + }, + 'meeting:setRecordingState': async (_event, args) => { + setTrayRecordingState(args.recording); + meetingRecordingActive = args.recording; + updateSelfCaptureState(); + // Recording started through another path — a lingering "Take Notes?" + // popup is stale now. + if (args.recording) closeMeetingPopup(); + return { success: true as const }; + }, + 'voice:setCallActive': async (_event, args) => { + voiceCallActive = args.active; + updateSelfCaptureState(); + return { success: true as const }; + }, + 'meeting:notifyNotesReady': async (_event, args) => { + // Granola-style re-entry point: the note refreshed in place, but the + // user has usually switched back to the meeting app — the notification + // brings them back. Suppressed while the app is focused. + void notifyIfEnabled('meeting_notes_ready', { + title: 'Meeting notes ready', + message: `Your notes for "${args.title}" are ready.`, + link: `rowboat://open?type=file&path=${encodeURIComponent(args.notePath)}`, + actionLabel: 'Open notes', + onlyWhenBackground: true, + }); + return { success: true as const }; + }, + 'meetingDetect:getPayload': async () => { + return { payload: getMeetingPopupPayload() }; + }, + 'meetingDetect:action': async (_event, args) => { + handleMeetingPopupAction(args.action); return {}; }, 'analytics:bootstrap': async () => { @@ -897,7 +978,7 @@ export function setupIpcHandlers() { return listImportantThreads({ cursor: args.cursor, limit: args.limit }); }, 'gmail:getEverythingElse': async (_event, args) => { - return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); + return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit, category: args.category }); }, 'gmail:triggerSync': async () => { triggerGmailSync(); @@ -931,6 +1012,33 @@ export function setupIpcHandlers() { const result = setThreadImportance(args.threadId, args.importance); return { ok: result.success, previous: result.previous, error: result.error }; }, + 'gmail:setCategory': async (_event, args) => { + const result = setThreadCategory(args.threadId, args.category); + return { ok: result.success, error: result.error }; + }, + 'gmail:archiveCategory': async (_event, args) => { + return archiveCategoryThreads(args.category); + }, + 'gmail:getEmailInstructions': async () => { + return { instructions: loadEmailInstructions() }; + }, + 'gmail:setEmailInstructions': async (_event, args) => { + const saved = saveEmailInstructions(args.instructions); + if (!saved.ok) return saved; + // Extract any custom labels the instructions define so they become + // valid classifier outputs immediately. Extraction failure shouldn't + // fail the save — the instructions themselves are already persisted + // and still steer classification as free text. + try { + await syncCustomLabelsFromInstructions(args.instructions); + } catch (err) { + console.warn('[EmailLabels] custom label extraction failed:', err); + } + return saved; + }, + 'gmail:getEmailLabels': async () => { + return { labels: getEmailLabels().map(({ id, name, kind }) => ({ id, name, kind })) }; + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, @@ -1051,6 +1159,14 @@ export function setupIpcHandlers() { await container.resolve('sessions').deleteSession(args.sessionId); return { success: true }; }, + 'turns:subscribe': async (event, args) => { + subscribeTurnDeltas(event.sender, args.turnId); + return { success: true }; + }, + 'turns:unsubscribe': async (event, args) => { + unsubscribeTurnDeltas(event.sender, args.turnId); + return { success: true }; + }, 'sessions:downloadLog': async (event, args) => { // Concatenate the session's turn logs into one JSONL for debugging. const sessions = container.resolve('sessions'); @@ -1630,6 +1746,15 @@ export function setupIpcHandlers() { for (const app of apps) { if (app.agentSlugs.length) await appsAgents.syncAppAgents(app); } + // The copilot instructions embed the installed-apps list. This handler + // is the one place that sees every change to the app set (installs, + // deletes, copilot-created folders — the renderer polls it), so refresh + // the instructions cache when the set actually changes. + const fingerprint = JSON.stringify(apps.map((a) => [a.folder, a.manifest?.name, a.manifest?.description, a.hasDist])); + if (fingerprint !== lastAppsFingerprint) { + lastAppsFingerprint = fingerprint; + invalidateCopilotInstructionsCache(); + } return { serverRunning: status.running, ...(status.error ? { serverError: status.error } : {}), @@ -1932,7 +2057,10 @@ export function setupIpcHandlers() { }, // Search handler 'search:query': async (_event, args) => { - return search(args.query, args.limit, args.types); + await sessionsIndexReady; + const sessions = container.resolve('sessions').listSessions() + .map((s) => ({ sessionId: s.sessionId, title: s.title })); + return search(args.query, args.limit, args.types, sessions); }, // Inline task schedule classification 'export:note': async (event, args) => { @@ -2188,6 +2316,9 @@ export function setupIpcHandlers() { if (main.isMinimized()) main.restore(); main.show(); main.focus(); + // The user is typically in another app (e.g. just left a meeting) — + // a plain focus() won't take the foreground from it. + app.focus({ steal: true }); } return {}; }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index b862782b..6ac902de 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,8 +1,9 @@ -import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron"; +import { app, BrowserWindow, desktopCapturer, dialog, protocol, net, shell, session, safeStorage, type Session } from "electron"; import path from "node:path"; +import os from "node:os"; import { setupIpcHandlers, - startRunsWatcher, startSessionsWatcher, markSessionsIndexReady, + startRunsWatcher, startSessionsWatcher, startTurnEventsWatcher, markSessionsIndexReady, startCodeRunFeedWatcher, startChannelsWatcher, startCodeSessionStatusWatcher, @@ -23,7 +24,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; -import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; @@ -36,7 +36,7 @@ import { init as initEventProcessor, registerConsumer } from "@x/core/dist/event import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js"; import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; -import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js"; +import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/assembly/skills/watcher.js"; import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js"; import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js"; import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js"; @@ -52,7 +52,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 type { ISessions } from "@x/core/dist/runtime/sessions/index.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -64,6 +64,11 @@ import { setMainWindowForDeepLinks, } from "./deeplink.js"; import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; +import { startModelsDevRefresh } from "@x/core/dist/models/models-dev.js"; +import { loadAppSettings, saveAppSettings } from "@x/core/dist/config/app_settings.js"; +import { init as initMeetingDetection } from "@x/core/dist/meetings/detector.js"; +import { createAppTray, hasTray, isRecordingActive, markPendingToggleMeetingNotes } from "./tray.js"; +import { initMeetingPopup, showMeetingPopup } from "./meeting-popup.js"; // Captured as early as possible so it reflects actual process start. Used to // gate grace-eligible notifications (e.g. the burst of background-task @@ -73,6 +78,24 @@ const APP_LAUNCHED_AT = Date.now(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// fs.watch failures (EMFILE fd exhaustion, ENOSPC watch limits) surface as +// uncaught exceptions from Node's watcher internals, bypassing chokidar's +// 'error' handlers. Watching is a degradable feature — log and keep running. +// Everything else keeps Electron's default behavior (native error dialog), +// which we replicate since registering any listener suppresses it. +process.on('uncaughtException', (err) => { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if ((code === 'EMFILE' || code === 'ENOSPC') && (err?.stack ?? '').includes('FSWatcher')) { + console.error('[Main] file watcher error (non-fatal):', err); + return; + } + console.error('[Main] uncaught exception:', err); + dialog.showErrorBox( + 'A JavaScript error occurred in the main process', + err?.stack ?? String(err), + ); +}); + // run this as early in the main process as possible if (started) app.quit(); @@ -257,7 +280,49 @@ function setupZoomShortcuts(win: BrowserWindow) { }); } -function createWindow() { +// Resident-app plumbing (Granola-style): the main window may exist hidden +// (launched at login) or not at all (closed on macOS) while the app keeps +// running from the tray. showApp() is the single "bring the app up" path +// used by the tray, the Dock, and pending tray commands. +let mainWindow: BrowserWindow | null = null; + +function showApp(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + if (!mainWindow.isVisible()) mainWindow.maximize(); + mainWindow.show(); + mainWindow.focus(); + } else { + createWindow(); + } + // The user is usually in another app (a meeting!) when this runs — a plain + // focus() won't take the foreground from it. + app.focus({ steal: true }); +} + +/** + * Was this process launched by the OS at login (rather than by the user)? + * Used to start with the window hidden so login launches are invisible. + * + * - Windows: our login item registers with an explicit --hidden arg. + * - macOS: wasOpenedAtLogin when available. On macOS 13+ (SMAppService) + * Electron doesn't reliably populate it (electron#37244), so fall back to + * a heuristic: a packaged launch while registered as a login item within + * two minutes of boot is treated as a login launch. + */ +function wasLaunchedAtLogin(): boolean { + if (process.argv.includes("--hidden")) return true; + if (process.platform !== "darwin" || !app.isPackaged) return false; + try { + const settings = app.getLoginItemSettings(); + if (settings.wasOpenedAtLogin) return true; + return settings.openAtLogin && os.uptime() < 120; + } catch { + return false; + } +} + +function createWindow(options: { startHidden?: boolean } = {}) { const win = new BrowserWindow({ width: 1280, height: 800, @@ -283,11 +348,18 @@ function createWindow() { configureSessionPermissions(session.defaultSession); configureSessionPermissions(session.fromPartition(BROWSER_PARTITION)); + mainWindow = win; setMainWindowForDeepLinks(win); - win.on("closed", () => setMainWindowForDeepLinks(null)); + win.on("closed", () => { + if (mainWindow === win) mainWindow = null; + setMainWindowForDeepLinks(null); + }); - // Show window when content is ready to prevent blank screen + // Show window when content is ready to prevent blank screen. + // Launched-at-login starts stay hidden: the app is reachable from the + // tray/Dock, and showApp() maximizes on first reveal. win.once("ready-to-show", () => { + if (options.startHidden) return; win.maximize(); win.show(); }); @@ -374,6 +446,12 @@ app.whenReady().then(async () => { // Initialize all config files before UI can access them await initConfigs(); + // Warm the models.dev catalog cache (single writer; refreshed every 24h + // while the app runs). Every consumer — catalog listings, the reasoning + // capability gate — reads the on-disk cache only. Best-effort: failures + // leave any existing cache in use and never block boot. + startModelsDevRefresh(); + // PostHog identify() is idempotent — call it on every startup so existing // signed-in installs (and every cold start of v0.3.4+) get re-identified. // Otherwise main-process events stay anonymous until the user re-signs-in. @@ -405,7 +483,79 @@ app.whenReady().then(async () => { console.error('[Apps] Failed to start:', error); }); - createWindow(); + // Resident app (Granola-style): register as an OS login item once, on the + // first packaged run. After that the OS registry is the source of truth — + // the Settings toggle writes it directly, and disabling the login item in + // System Settings sticks because we never re-register on boot. + if (app.isPackaged && !loadAppSettings().loginItemRegistered) { + try { + app.setLoginItemSettings({ + openAtLogin: true, + ...(process.platform === "win32" ? { args: ["--hidden"] } : {}), + }); + saveAppSettings({ loginItemRegistered: true }); + } catch (error) { + console.error("[LoginItem] Failed to register login item:", error); + } + } + + createWindow({ startHidden: wasLaunchedAtLogin() }); + + // Menu bar icon: open the app / start-stop meeting notes without the + // window. If the renderer isn't ready to receive the toggle (window closed + // or still loading), park it as a pending command the renderer drains on + // mount — same pull pattern as pending deep links. + createAppTray({ + openApp: showApp, + toggleMeetingNotes: () => { + const hadWindow = mainWindow !== null && !mainWindow.isDestroyed(); + showApp(); + const win = mainWindow; + if (!hadWindow || !win || win.webContents.isLoading()) { + markPendingToggleMeetingNotes(); + return; + } + win.webContents.send("app:toggleMeetingNotes", null); + }, + }); + + // Ambient meeting detection (Granola-style): the mic-monitor helper + + // running-app scan produce "Meeting detected" events; the popup asks + // before anything records. Clicking "Take Notes" routes into the same + // renderer flow as the calendar notification. + initMeetingPopup({ + onTakeNotes: (meeting) => { + showApp(); + // The user may have started recording between popup and click — + // sending the take-notes flow then would toggle it OFF. + if (isRecordingActive()) return; + const payload = { + event: meeting.calendarEvent ?? { summary: meeting.noteTitle }, + openMeeting: false, + source: "detected", + }; + const win = mainWindow; + if (!win || win.isDestroyed()) return; + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + if (!win.isDestroyed()) win.webContents.send("app:takeMeetingNotes", payload); + }); + return; + } + win.webContents.send("app:takeMeetingNotes", payload); + }, + }); + initMeetingDetection({ + helperPath: path.join(__dirname, "mic-monitor"), + onDetected: (meeting) => showMeetingPopup(meeting), + // Call ended while recording (meeting app released the mic) — the + // renderer stops capture and generates notes, same as a manual stop. + onExternalCallEnded: () => { + const win = mainWindow; + if (!win || win.isDestroyed() || win.webContents.isLoading()) return; + win.webContents.send("meeting:externalCallEnded", null); + }, + }); // Start workspace watcher as a main-process service // Watcher runs independently and catches ALL filesystem changes: @@ -447,6 +597,9 @@ app.whenReady().then(async () => { markSessionsIndexReady(); } startSessionsWatcher(); + // Turn event spine: durable events of every turn (session, headless, + // sub-agent) → renderer, for turnId-keyed live views. + startTurnEventsWatcher(); startCodeRunFeedWatcher(); // Mobile channels (WhatsApp/Telegram bridge): needs the session index, so @@ -505,9 +658,6 @@ app.whenReady().then(async () => { // start knowledge graph builder initGraphBuilder(); - // start email labeling service - initEmailLabeling(); - // start note tagging service initNoteTagging(); @@ -530,14 +680,16 @@ app.whenReady().then(async () => { initChromeSync(); app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } + // Reveal the hidden/closed main window (login launches start hidden). + showApp(); }); }); app.on("window-all-closed", () => { - if (process.platform !== "darwin") { + // Resident app: with a tray present, keep running with no windows so + // meeting detection/notifications stay alive (Granola-style). Without a + // tray (creation failed), fall back to the platform-default quit. + if (process.platform !== "darwin" && !hasTray()) { app.quit(); } }); diff --git a/apps/x/apps/main/src/meeting-popup.ts b/apps/x/apps/main/src/meeting-popup.ts new file mode 100644 index 00000000..5c5f7d53 --- /dev/null +++ b/apps/x/apps/main/src/meeting-popup.ts @@ -0,0 +1,160 @@ +import { app, BrowserWindow, screen } from "electron"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js"; + +/** + * The "Meeting detected — Take Notes?" popup: a small frameless panel in the + * top-left corner of the display the user is on. A macOS panel (NSPanel) at + * screen-saver level with fullscreen-auxiliary behavior, so it floats over + * whatever the user is looking at — including a fullscreen Meet/Zoom — and + * appears without stealing focus. Auto-dismisses after a while and never + * records anything by itself: clicking "Take Notes" hands off to the main + * window's existing take-meeting-notes flow. + */ + +// The window IS the card: exact card dimensions, card background, native +// rounded corners + shadow. No transparency — macOS panels don't honor +// `transparent: true` reliably and paint a grey backing slab instead. +const POPUP_WIDTH = 376; +const POPUP_HEIGHT = 48; +// The popup renderer owns the real 45s countdown (it pauses on hover and +// draws the progress line); this is only a crash-safety net so a wedged +// renderer can't leave the popup on screen forever. +const FALLBACK_DISMISS_MS = 180_000; + +// Display names, Granola-style ("Chrome", not "Google Chrome"). +const SHORT_APP_NAMES: Record = { + "Google Chrome": "Chrome", + "Microsoft Edge": "Edge", + "Brave Browser": "Brave", + "Microsoft Teams": "Teams", +}; + +export interface MeetingPopupPayload { + title: string; + message: string; + hasCalendarEvent: boolean; +} + +let popupWin: BrowserWindow | null = null; +let currentPayload: MeetingPopupPayload | null = null; +let currentMeeting: DetectedMeeting | null = null; +let dismissTimer: NodeJS.Timeout | null = null; +let onTakeNotes: ((meeting: DetectedMeeting) => void) | null = null; + +/** Main registers the take-notes handoff once at startup. */ +export function initMeetingPopup(handlers: { onTakeNotes: (meeting: DetectedMeeting) => void }): void { + onTakeNotes = handlers.onTakeNotes; +} + +export function getMeetingPopupPayload(): MeetingPopupPayload | null { + return currentPayload; +} + +export function handleMeetingPopupAction(action: "take-notes" | "dismiss"): void { + const meeting = currentMeeting; + closeMeetingPopup(); + if (action === "take-notes" && meeting) { + onTakeNotes?.(meeting); + } +} + +export function closeMeetingPopup(): void { + if (dismissTimer) { + clearTimeout(dismissTimer); + dismissTimer = null; + } + currentPayload = null; + currentMeeting = null; + if (popupWin && !popupWin.isDestroyed()) popupWin.destroy(); + popupWin = null; +} + +export function showMeetingPopup(meeting: DetectedMeeting): void { + const eventSummary = + typeof meeting.calendarEvent?.summary === "string" + ? (meeting.calendarEvent.summary as string).trim() + : ""; + const appLabel = SHORT_APP_NAMES[meeting.appName] ?? meeting.appName; + const payload: MeetingPopupPayload = { + // Lean two-liner: "Meeting detected" / "Chrome" — or the event name + // over the platform when a calendar event is happening now. + title: eventSummary ? eventSummary : meeting.title, + message: appLabel, + hasCalendarEvent: Boolean(meeting.calendarEvent), + }; + + // Replace any popup that's still up (stale detection loses to fresh). + closeMeetingPopup(); + currentPayload = payload; + currentMeeting = meeting; + + // Top-left of the display the user is actually on (cursor display), not + // necessarily the primary one. + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const workArea = display.workArea; + const popupDir = path.dirname(fileURLToPath(import.meta.url)); + const preloadPath = app.isPackaged + ? path.join(popupDir, "../preload/dist/preload.js") + : path.join(popupDir, "../../../preload/dist/preload.js"); + + const win = new BrowserWindow({ + width: POPUP_WIDTH, + height: POPUP_HEIGHT, + x: workArea.x + 24, + // Sit a bit clear of the menu bar rather than hugging it. + y: workArea.y + 44, + // NSPanel (macOS): non-activating, and — unlike a regular window with + // visibleOnFullScreen — can float over fullscreen Spaces without + // turning Rowboat into an "agent" app that loses its Dock icon. + ...(process.platform === "darwin" ? { type: "panel" as const } : {}), + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + backgroundColor: "#1d1d1d", + hasShadow: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + preload: preloadPath, + }, + }); + // Screen-saver level + fullscreen-auxiliary: visible over fullscreen + // meeting apps on every workspace — the whole point of the popup is to be + // seen while the user is IN the meeting, wherever that is. + win.setAlwaysOnTop(true, "screen-saver"); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // visibleOnFullScreen flips the app's activation policy to "accessory": + // the Dock icon disappears and the app can no longer take foreground + // focus — clicking "Take Notes" then hands focus to whatever is next in + // the window stack instead of Rowboat. Restore the policy immediately: + // the popup keeps its fullscreen-auxiliary collection behavior (it's a + // non-activating panel, same trick as Zoom's floating controls), while + // Rowboat stays a regular app. + if (process.platform === "darwin") { + void app.dock?.show(); + } + win.webContents.once("did-finish-load", () => { + if (win.isDestroyed()) return; + win.webContents.send("meetingDetect:payload", payload); + // showInactive: the user is in a meeting app right now — appearing + // must not steal focus from it. + win.showInactive(); + }); + win.on("closed", () => { + if (popupWin === win) popupWin = null; + }); + popupWin = win; + + if (app.isPackaged) { + win.loadURL("app://-/index.html#meeting-detected"); + } else { + win.loadURL("http://localhost:5173/#meeting-detected"); + } + + dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS); +} diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts index 738d861a..45a5207d 100644 --- a/apps/x/apps/main/src/test-agent.ts +++ b/apps/x/apps/main/src/test-agent.ts @@ -1,5 +1,5 @@ -import * as runsCore from '@x/core/dist/runs/runs.js'; -import { bus } from '@x/core/dist/runs/bus.js'; +import * as runsCore from '@x/core/dist/runtime/legacy/runs.js'; +import { bus } from '@x/core/dist/runtime/legacy/bus.js'; async function main() { const { id } = await runsCore.createRun({ diff --git a/apps/x/apps/main/src/tray.ts b/apps/x/apps/main/src/tray.ts new file mode 100644 index 00000000..2d55081d --- /dev/null +++ b/apps/x/apps/main/src/tray.ts @@ -0,0 +1,167 @@ +import { app, Menu, Tray, nativeImage } from "electron"; + +/** + * Menu bar / system tray presence (Granola-style resident app). + * + * The icon is the app glyph pre-rendered as a macOS "template" image + * (pure black + alpha, derived from icons/icon.png: alpha = pixel + * luminance, so the white sail becomes an opaque black shape and the + * black rounded square becomes transparent). Embedded as base64 so the + * tray never depends on asset paths that differ between dev and + * packaged layouts. Template rendering makes macOS tint it correctly + * in light/dark menu bars and while highlighted. + */ +const TRAY_ICON_18 = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAABRElEQVR4nKySMS9EQRSF7773REEUlFpRiEg0Oj1RqTQqCiLRKNV+gkLvF5CoJRqJPyBEqyMURJB9zzl5Z3bvTuZtdjdu8mVm7sw9c+fOzWw0a4HMO3Ib3DJRaV0NK5QrqHLBM2AB/IAvOgrrn0EJ2pqvgg2N82AarIBX7jcJtSQyCXbALliKzryDJ83LIiEQnrAFTsBcOOxg3CN4C4FFQmQCnINN+b/BmPZDvTi/0T597SwS4dtvwTK4VPrj1mu5zl9oXQWBMDKTfXAPruSfBcfgwOqi03j5A1h0vo5QJ0W3Dr9GOwN7eiYzPASn/owXSgmEBmTfsLhT4NnqHvqMg2IrozkvewHXyvpIIj2xqYyajJncWf2bPuvGjFL76+ADbFu3WW0YIdaHvcYfWrOoLt4GeVquC3+t228jCfmzVb/b/sX+AAAA//+fzjrgAAAABklEQVQDAHsTRGNUkus5AAAAAElFTkSuQmCC"; +const TRAY_ICON_36 = + "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC3klEQVR4nOyYu4sUQRDGa3fm8DgxUxB8ICqKqBj4QBOFMxMDRQUfgS/0D1A0MRQMTA1MxBcngpFmBiocgokgCopo4PnCSE193e74FV11XTtMz+zs9sLB3Qc/drenp/ubrqruuUtpmimlaaYZb6gBmvK9DbKiDoNUU+bIxEClEoqvRIxkOYbBcrAJ7ALzwATlFiVWyDQULYE1F2wE28E2sBYsJr8I58Fjc18UQw2hLYOOgJ1gHxgFSwru+SsmXsnvjjzq1ZA1wgOuBMfBQXJhUbUF7c8akraJWIZ4yVsyEIfhjBgZket6TRPa5mkmbd/AZ9PWk6GGmXAhuABOgTnSPikmmoH79F7+/Rr8IV+BtQ3xJFq2HJpLYkqNJNS5EkXbiVYb65kZt1XXkIZoAbhKLmGtkdRMFNrXMmOA+4zn2qdUtTGqma1gDKwAv2XgtOD+MkOaV5w/XAS/ijqmXZjZA+6QT9ph00erLDGTFpnitkn5fCRmbBpUGtLYHgU3pe0teAKegnVgL7kqI+lrjwlrKh+ue1Si0NPwICfBFXAX3CaXiP9MP16V3eSqbXOFKb32BawmF/ZCNQvaeDDOlWVgvRgbFzOJgSd5QO5YuEyd+5OaUnRzHBMzwTM0lIRD5FcjofBpnZDPIzZ1jnz12Qdk8ZGxBnykgv2nyhDLnthl0oOVjb0AG8itlOYnPxg/4C1wjALJrCp7/agykjfF/b+CI9QZNq2ww+BH1UBNiiPNkYfgHflQtuT7NfCe/KoHFfMFTZN6PthBblU4bLwq+ymwEeYVa4WsXspnW8Y/C75TSSJbDeIlf1Q+eUe/T24PK01kq9gv+fyAb8Aq8IncK+xPudZVkcQKmY6zhfzBeYBc/nQVKlXskJ0Wc4fAc6oRKlWMkOkKLCUXphPgBvmqqz1Yv9K95SL4AK73aiaWIRYfDYvInVO1w0QDMKTqywwr5k5dq5pCGsTf9n1p9h9WVfoPAAD//0eu+ckAAAAGSURBVAMAjdCu7L3gD4wAAAAASUVORK5CYII="; + +interface TrayActions { + openApp: () => void; + toggleMeetingNotes: () => void; +} + +let tray: Tray | null = null; +let actions: TrayActions | null = null; +let recording = false; + +// Tray commands issued while the renderer wasn't ready to receive them +// (window closed or still loading). Drained by the renderer on mount via +// app:consumePendingTrayCommand — same pull pattern as pending deep links. +let pendingToggleMeetingNotes = false; + +export function markPendingToggleMeetingNotes(): void { + pendingToggleMeetingNotes = true; +} + +export function consumePendingToggleMeetingNotes(): boolean { + const value = pendingToggleMeetingNotes; + pendingToggleMeetingNotes = false; + return value; +} + +function buildTrayIcon() { + const icon = nativeImage.createEmpty(); + icon.addRepresentation({ + scaleFactor: 1, + buffer: Buffer.from(TRAY_ICON_18, "base64"), + }); + icon.addRepresentation({ + scaleFactor: 2, + buffer: Buffer.from(TRAY_ICON_36, "base64"), + }); + icon.setTemplateImage(true); + return icon; +} + +export function createAppTray(trayActions: TrayActions): void { + if (tray) return; + actions = trayActions; + + try { + tray = new Tray(buildTrayIcon()); + } catch (error) { + // Tray support can be missing (some Linux environments). The app just + // behaves as before: no resident presence. + console.error("[Tray] Failed to create tray:", error); + return; + } + + rebuildMenu(); + + // macOS opens the context menu on any click. On Windows/Linux a plain + // left-click should open the app; the menu stays on right-click. + if (process.platform !== "darwin") { + tray.on("click", () => actions?.openApp()); + } +} + +export function hasTray(): boolean { + return tray !== null; +} + +export function isRecordingActive(): boolean { + return recording; +} + +export function setTrayRecordingState(isRecording: boolean): void { + if (recording === isRecording) return; + recording = isRecording; + rebuildMenu(); + if (isRecording) startWaveAnimation(); + else stopWaveAnimation(); +} + +// --- Recording indicator: animated mini-waveform beside the tray icon --- +// macOS renders tray titles to the right of the icon. Braille cells give +// 1-dot-wide bars (two bars per character, four height steps each) — a slim +// waveform, an unmissable "Rowboat is capturing this meeting" signal. + +const WAVE_FRAME_MS = 300; +const WAVE_BAR_COUNT = 5; +// Dot bits for a bar of height 1–4 (index 0–3), built bottom-up. Two bars +// per braille cell (left column: dots 7,3,2,1 — right column: dots 8,6,5,4) +// keeps the columns tightly packed; the sine wave keeps every bar ≥1 dot so +// no column ever reads as missing. +const WAVE_LEFT_BITS = [0x40, 0x44, 0x46, 0x47]; +const WAVE_RIGHT_BITS = [0x80, 0xa0, 0xb0, 0xb8]; +// Radians per bar / per frame: together they make the crest travel smoothly +// leftward across the five bars. +const WAVE_SPATIAL_STEP = 1.1; +const WAVE_PHASE_STEP = 0.9; + +let waveTimer: NodeJS.Timeout | null = null; +let wavePhase = 0; + +function waveString(phase: number): string { + const levels: number[] = []; + for (let i = 0; i < WAVE_BAR_COUNT; i++) { + const level = Math.round(1.5 + 1.5 * Math.sin(phase + i * WAVE_SPATIAL_STEP)); + levels.push(Math.min(3, Math.max(0, level))); + } + let out = ""; + for (let i = 0; i < levels.length; i += 2) { + const left = WAVE_LEFT_BITS[levels[i]]; + const right = levels[i + 1] !== undefined ? WAVE_RIGHT_BITS[levels[i + 1]] : 0; + out += String.fromCharCode(0x2800 + left + right); + } + return out; +} + +function startWaveAnimation(): void { + if (!tray || process.platform !== "darwin") return; + stopWaveAnimation(); + waveTimer = setInterval(() => { + if (!tray) return; + wavePhase += WAVE_PHASE_STEP; + tray.setTitle(` ${waveString(wavePhase)}`, { fontType: "monospaced" }); + }, WAVE_FRAME_MS); +} + +function stopWaveAnimation(): void { + if (waveTimer) { + clearInterval(waveTimer); + waveTimer = null; + } + if (tray && process.platform === "darwin") tray.setTitle(""); +} + +function rebuildMenu(): void { + if (!tray) return; + const menu = Menu.buildFromTemplate([ + { label: "Open Rowboat", click: () => actions?.openApp() }, + recording + ? { + label: "Stop recording and generate notes", + click: () => actions?.toggleMeetingNotes(), + } + : { + label: "Start meeting notes", + click: () => actions?.toggleMeetingNotes(), + }, + { type: "separator" }, + { label: "Quit Rowboat", click: () => app.quit() }, + ]); + tray.setContextMenu(menu); + tray.setToolTip(recording ? "Rowboat — recording meeting" : "Rowboat"); +} diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 0a8c9f9f..15b53e2d 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "typecheck": "tsc -b", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", @@ -48,7 +49,7 @@ "@x/shared": "workspace:*", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "ai": "^5.0.117", + "ai": "^7.0.22", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index b83e7dd7..6a7b644a 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -460,12 +460,24 @@ font-size: 13px; } +/* The bold lead (summary or subject) may shrink — with ellipsis — but only + after the snippet span (huge shrink factor below) has fully collapsed. + Without this, a long summary plus the never-shrinking category chip + overflows the clipped row and the chip is what gets cut off. */ .gmail-row-content strong { - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; color: inherit; font-weight: 400; } +/* Snippet gives way first (chips carry .gmail-row-chip and keep shrink 0). */ +.gmail-row-content > span:not(.gmail-row-chip) { + flex-shrink: 1000; +} + .gmail-row-date { justify-self: end; color: var(--gm-text-faint); @@ -473,6 +485,114 @@ font-variant-numeric: tabular-nums; } +/* Category / waiting-age pill at the right edge of the content column. */ +.gmail-row-chip { + flex-shrink: 0; + margin-left: auto; + padding: 1px 8px; + border: 1px solid var(--gm-border); + border-radius: 999px; + color: var(--gm-text-faint); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +/* Two chips on one row (category + status): only the first takes the auto + margin; the second sits right next to it, so the pair clusters at the + right edge instead of spreading across the free space. */ +.gmail-row-chip + .gmail-row-chip { + margin-left: 6px; +} + +.gmail-row-chip-waiting { + border-color: transparent; + background: var(--gm-bg-pill-hover); + color: var(--gm-text-muted); + font-variant-numeric: tabular-nums; +} + +.gmail-row-chip-ready { + border-color: transparent; + background: var(--gm-bg-row-selected); + color: var(--gm-accent); +} + +/* Shown in place of the "Needs you" section when it's empty. */ +.gmail-caughtup { + padding: 18px 24px; + color: var(--gm-text-faint); + font-size: 13px; +} + +/* Category filter pills under the "Everything else" header. */ +.gmail-category-pills { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 24px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-category-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border: 1px solid var(--gm-border); + border-radius: 999px; + background: transparent; + color: var(--gm-text-muted); + font-family: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-category-pill:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-category-pill-active { + background: var(--gm-bg-row-selected); + border-color: var(--gm-accent); + color: var(--gm-text-strong); +} + +.gmail-category-pill-count { + color: var(--gm-text-faint); + font-variant-numeric: tabular-nums; +} + +.gmail-category-pill-archive { + margin-left: auto; + border-color: transparent; + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-category-pill-archive:disabled { + opacity: 0.6; + cursor: default; +} + +/* The category chip in the thread-detail toolbar is a button (dropdown trigger). */ +.gmail-category-chip { + flex-shrink: 0; + background: transparent; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-category-chip:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + .gmail-detail { display: flex; min-width: 0; @@ -1240,10 +1360,14 @@ } .graph-view { - background-color: var(--background); + background-color: #f8f8f9; user-select: none; } +.dark .graph-view { + background-color: #0b0b0d; +} + .graph-view::before { content: ''; position: absolute; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4bfec2cd..f61d8200 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; import { RunEvent } from '@x/shared/src/runs.js'; -import type { LanguageModelUsage, ToolUIPart } from 'ai'; +import type { ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; @@ -51,6 +51,7 @@ import { import { Message, MessageContent, + MessageCopyButton, MessageResponse, } from '@/components/ai-elements/message'; import { @@ -115,9 +116,11 @@ import { isErrorMessage, isToolCall, isToolGroup, + isTurnUsageMessage, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, + REASONING_EFFORT_LABELS, toToolState, } from '@/lib/chat-conversation' import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js' @@ -134,6 +137,7 @@ import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' import { playAckCue } from '@/lib/call-sounds' import { useTheme } from '@/contexts/theme-context' +import { TokenUsageMenu } from '@/components/token-usage-menu' type DirEntry = z.infer type RunEventType = z.infer @@ -455,7 +459,17 @@ const buildBgTaskSetupPrompt = (description: string) => const buildBgTaskEditPrompt = (slug: string) => `Let's tweak the background task \`${slug}\`. Please load the \`background-task\` skill first, read the task's current \`bg-tasks/${slug}/task.yaml\`, then ask me what I want to change.` -const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { +// The renderer displays our internal (flat) usage shape that arrives over IPC, +// not the AI SDK's restructured LanguageModelUsage (nested token details). +type UsageSummary = { + inputTokens?: number + outputTokens?: number + totalTokens?: number + reasoningTokens?: number + cachedInputTokens?: number +} + +const normalizeUsage = (usage?: UsageSummary | null): UsageSummary | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') if (!hasNumbers) return null @@ -957,7 +971,7 @@ function App() { return } }, [conversation]) - const [, setModelUsage] = useState(null) + const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) // New runtime: the active session's chat data + actions. All logic lives in // SessionChatStore (tested headlessly); the hook is a thin subscription. @@ -1070,6 +1084,13 @@ function App() { // floating popout; camera on → full-screen call; camera off → popout // (mascot pill). Handlers live below the voice/submit plumbing they drive. const video = useVideoMode() + // Assistant calls hold the mic — tell main so ambient meeting detection + // doesn't mistake our own capture for an external meeting. + useEffect(() => { + void window.ipc + .invoke('voice:setCallActive', { active: video.state !== 'idle' }) + .catch(() => { /* detection may be unavailable */ }) + }, [video.state]) const [inCall, setInCall] = useState(false) const inCallRef = useRef(false) // User explicitly shrank the full-screen call to the floating pill. @@ -1088,6 +1109,24 @@ function App() { handleToggleMeetingRef.current?.() }) + // Keep the tray menu in sync with meeting capture ("Start meeting notes" + // vs "Stop recording & generate notes"). + useEffect(() => { + void window.ipc + .invoke('meeting:setRecordingState', { recording: meetingTranscription.state === 'recording' }) + .catch(() => { /* tray may be unavailable */ }) + }, [meetingTranscription.state]) + + // Main detected the meeting app released the mic (call ended) — stop and + // generate notes, exactly like a manual stop. Listener only exists while + // recording, so a stale signal can never toggle a new recording ON. + useEffect(() => { + if (meetingTranscription.state !== 'recording') return + return window.ipc.on('meeting:externalCallEnded', () => { + handleToggleMeetingRef.current?.() + }) + }, [meetingTranscription.state]) + // Check if voice is available on mount and when OAuth state changes const refreshVoiceAvailability = useCallback(() => { Promise.all([ @@ -1428,6 +1467,9 @@ function App() { const chatViewStateByTabRef = useRef(chatViewStateByTab) const chatDraftsRef = useRef(new Map()) const selectedModelByTabRef = useRef(new Map()) + // Reasoning effort is per-tab, next-turn intent like the model selection — + // but unlike model it is never frozen on a run; it applies turn by turn. + const reasoningEffortByTabRef = useRef(new Map()) // Work directory is per-chat. Keyed by tab id; null/absent means none set. const [workDirByTab, setWorkDirByTab] = useState>({}) const workDirByTabRef = useRef(workDirByTab) @@ -1597,6 +1639,7 @@ function App() { runId, conversation, currentAssistantMessage, + sessionUsage: {}, pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), @@ -2969,6 +3012,7 @@ function App() { // Per-message turn config. Composition inputs land in the system prompt // via the agent resolver; keep them session-sticky where possible so the // provider prefix cache survives across turns. + const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId) const sendConfig = { agent: { agentId, @@ -2986,6 +3030,7 @@ function App() { }, }, autoPermission: (permissionMode ?? 'manual') === 'auto', + ...(reasoningEffort ? { reasoningEffort } : {}), } const userMessageContextFor = (middlePane: Awaited>) => ({ currentDateTime: new Date().toISOString(), @@ -3141,7 +3186,6 @@ function App() { toolCallId: string, subflow: string[], response: 'approve' | 'deny', - scope?: 'once' | 'session' | 'always', ) => { if (!runId) return @@ -3150,7 +3194,6 @@ function App() { await sessionChat.respondToPermission( toolCallId, response === 'approve' ? 'allow' : 'deny', - scope ? { scope } : undefined, ) } catch (error) { console.error('Failed to authorize permission:', error) @@ -3333,6 +3376,7 @@ function App() { }) chatDraftsRef.current.delete(tabId) selectedModelByTabRef.current.delete(tabId) + reasoningEffortByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setWorkDirByTab((prev) => { if (!(tabId in prev)) return prev @@ -4677,11 +4721,23 @@ function App() { return () => observer.disconnect() }, []) + // Tray menu "Start/Stop meeting notes": same toggle as the Meetings header + // button. Also drains a toggle parked while the window was closed/loading + // (mirrors the pending deep-link pull above). + useEffect(() => { + void window.ipc.invoke('app:consumePendingTrayCommand', null).then(({ toggleMeetingNotes }) => { + if (toggleMeetingNotes) handleToggleMeetingRef.current?.() + }) + return window.ipc.on('app:toggleMeetingNotes', () => { + handleToggleMeetingRef.current?.() + }) + }, []) + // Triggered by main when the user clicks a calendar-meeting notification. // Reuses the same flow as the in-app "Join meeting & take notes" button. // When `openMeeting` is true, also opens the meeting URL in the system browser. useEffect(() => { - return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, source }) => { const e = event as { summary?: string start?: { dateTime?: string; date?: string; timeZone?: string } @@ -4705,7 +4761,7 @@ function App() { location: e.location, htmlLink: e.htmlLink, conferenceLink, - source: 'calendar-sync', + source: source ?? 'calendar-sync', } window.dispatchEvent(new Event('calendar-block:join-meeting')) }) @@ -4780,6 +4836,7 @@ function App() { case 'knowledge': void navigateToView({ type: 'knowledge-view' }); break case 'workspace': void navigateToView({ type: 'workspace' }); break case 'code': void navigateToView({ type: 'code' }); break + case 'apps': openAppsView(); break } } @@ -5635,6 +5692,14 @@ function App() { }) // Refresh the file view await handleVoiceNoteCreated(notePath) + // Notes are done — bring Rowboat to the foreground on the + // finished note (the post-call "redirect"). The notification + // below is background-only, so it only fires if the focus + // grab didn't take. + void window.ipc.invoke('app:focusMainWindow', null).catch(() => {}) + void window.ipc + .invoke('meeting:notifyNotesReady', { notePath, title: noteTitle }) + .catch(() => { /* notification is best-effort */ }) } } } catch (err) { @@ -5906,14 +5971,17 @@ function App() { {item.content && ( - - - {item.content} - - +
+ + + {item.content} + + + +
)} ) @@ -5921,26 +5989,29 @@ function App() { const { message, files } = parseAttachedFiles(item.content) return ( - - {files.length > 0 && ( -
- {files.map((filePath, index) => ( - - @{wikiLabel(filePath)} - - ))} -
- )} - - {message} - -
+
+ + {files.length > 0 && ( +
+ {files.map((filePath, index) => ( + + @{wikiLabel(filePath)} + + ))} +
+ )} + + {message} + +
+ +
) } @@ -6039,6 +6110,24 @@ function App() { ) } + if (isTurnUsageMessage(item)) { + return ( +
+ + {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )} +
+ ) + } + if (isErrorMessage(item)) { if (matchBillingError(item.message)) { return null @@ -6062,6 +6151,7 @@ function App() { ? { runId, ...sessionChat.chatState } : { runId, + sessionUsage: {}, conversation: sessionLoadErrorItems.length > 0 ? sessionLoadErrorItems : conversation, currentAssistantMessage, pendingAskHumanRequests, @@ -6174,6 +6264,20 @@ function App() { onOpenApps={openAppsView} recentRuns={runs} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onRenameRun={(rid, title) => { + void window.ipc.invoke('sessions:setTitle', { sessionId: rid, title }) + .then(() => setRuns((prev) => prev.map((r) => (r.id === rid ? { ...r, title } : r)))) + .catch((err) => console.error('Failed to rename chat:', err)) + }} + onDeleteRun={(rid) => { + void window.ipc.invoke('sessions:delete', { sessionId: rid }) + .then(() => { + setRuns((prev) => prev.filter((r) => r.id !== rid)) + const openTab = chatTabs.find((t) => t.runId === rid) + if (openTab) closeChatTab(openTab.id) + }) + .catch((err) => console.error('Failed to delete chat:', err)) + }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenEmail={(threadId) => openEmailView(threadId)} onOpenHome={() => void navigateToView({ type: 'home' })} @@ -6224,6 +6328,7 @@ function App() { onNewChatTab={handleNewChatTab} recentRuns={runs} activeRunId={runId} + sessionUsage={activeChatTabState.sessionUsage} onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} /> @@ -6497,6 +6602,11 @@ function App() { currentRunId={runId} processingRunIds={processingRunIds} onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onRenameRun={(rid, title) => { + void window.ipc.invoke('sessions:setTitle', { sessionId: rid, title }) + .then(() => setRuns((prev) => prev.map((r) => (r.id === rid ? { ...r, title } : r)))) + .catch((err) => console.error('Failed to rename chat:', err)) + }} onDeleteRun={async (rid) => { try { await window.ipc.invoke('sessions:delete', { sessionId: rid }) @@ -6795,8 +6905,6 @@ function App() { toolCall={permRequest.toolCall} permission={permRequest.permission} onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} isProcessing={isActive && activeIsWorking} response={response} @@ -6880,6 +6988,13 @@ function App() { selectedModelByTabRef.current.delete(tab.id) } }} + onReasoningEffortChange={(effort) => { + if (effort) { + reasoningEffortByTabRef.current.set(tab.id, effort) + } else { + reasoningEffortByTabRef.current.delete(tab.id) + } + }} workDir={workDirByTab[tab.id] ?? null} onWorkDirChange={(v) => setTabWorkDir(tab.id, v)} isRecording={isActive && isRecording} @@ -6947,6 +7062,7 @@ function App() { onOpenFullScreen={toggleRightPaneMaximize} conversation={activeChatTabState.conversation} currentAssistantMessage={activeChatTabState.currentAssistantMessage} + sessionUsage={activeChatTabState.sessionUsage} chatTabStates={chatTabStatesForRender} viewportAnchors={chatViewportAnchorByTab} isProcessing={activeIsProcessing} @@ -6968,6 +7084,13 @@ function App() { selectedModelByTabRef.current.delete(tabId) } }} + onReasoningEffortChangeForTab={(tabId, effort) => { + if (effort) { + reasoningEffortByTabRef.current.set(tabId, effort) + } else { + reasoningEffortByTabRef.current.delete(tabId) + } + }} workDirByTab={workDirByTab} onWorkDirChangeForTab={setTabWorkDir} codeSessionLocks={codeSessionLocks} diff --git a/apps/x/apps/renderer/src/components/ai-elements/context.tsx b/apps/x/apps/renderer/src/components/ai-elements/context.tsx index f49d7cae..02cc4fa1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/context.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/context.tsx @@ -8,7 +8,6 @@ import { } from "@/components/ui/hover-card"; import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; -import type { LanguageModelUsage } from "ai"; import { type ComponentProps, createContext, useContext } from "react"; import { getUsage } from "tokenlens"; @@ -20,10 +19,20 @@ const ICON_STROKE_WIDTH = 2; type ModelId = string; +// Our internal (flat) usage shape received over IPC, not the AI SDK's +// restructured LanguageModelUsage (nested token details as of AI SDK 7). +type UsageSummary = { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + cachedInputTokens?: number; +}; + type ContextSchema = { usedTokens: number; maxTokens: number; - usage?: LanguageModelUsage; + usage?: UsageSummary; modelId?: ModelId; }; diff --git a/apps/x/apps/renderer/src/components/ai-elements/message.tsx b/apps/x/apps/renderer/src/components/ai-elements/message.tsx index ec3acfc1..fcb5250c 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/message.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/message.tsx @@ -14,8 +14,10 @@ import { import { cn } from "@/lib/utils"; import type { FileUIPart, UIMessage } from "ai"; import { + CheckIcon, ChevronLeftIcon, ChevronRightIcon, + CopyIcon, PaperclipIcon, XIcon, } from "lucide-react"; @@ -38,6 +40,37 @@ export const Message = ({ className, from, ...props }: MessageProps) => ( /> ); +/** + * Minimal copy-to-clipboard affordance for a message bubble. Invisible until + * the surrounding Message (`.group`) is hovered. + */ +export const MessageCopyButton = ({ + text, + className, +}: { + text: string; + className?: string; +}) => { + const [copied, setCopied] = useState(false); + return ( + + ); +}; + export type MessageContentProps = HTMLAttributes; export const MessageContent = ({ @@ -49,7 +82,7 @@ export const MessageContent = ({ data-slot="message-content" className={cn( "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm", - "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", + "group-[.is-user]:ml-auto group-[.is-user]:rounded-2xl group-[.is-user]:rounded-tr-md group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-foreground", "group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground", className )} diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index cdcafc27..961e85f9 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -52,10 +52,28 @@ export const PermissionRequest = ({ : JSON.stringify(toolCall.arguments)) : null; const filePermission = permission?.kind === "file" ? permission : null; + const externalAction = + permission?.kind === "composio" + ? { label: "Composio action", detail: `${permission.toolSlug} (${permission.toolkitSlug})` } + : permission?.kind === "mcp" + ? { + label: "MCP tool", + detail: permission.serverName + ? `${permission.toolName} on ${permission.serverName}` + : permission.toolName, + } + : null; const isResponded = response !== null; const isApproved = response === 'approve'; + // Scope actions ("Allow for Session"/"Always Allow") render only when the + // caller wires them: the legacy code-mode path persists grants, but the + // turns path has no grant persistence yet and must not show dead buttons. + const hasScopeActions = + Boolean(onApproveSession || onApproveAlways) && + Boolean(command || filePermission); + // Once a response is chosen, collapse the details to just the header. // Users can click the header to expand them again. const [expanded, setExpanded] = useState(false); @@ -139,6 +157,16 @@ export const PermissionRequest = ({ )} + {showDetails && externalAction && ( +
+

+ {externalAction.label} +

+

+ {externalAction.detail} +

+
+ )} {showDetails && !command && !filePermission && toolCall.arguments && (

@@ -159,12 +187,12 @@ export const PermissionRequest = ({ size="sm" onClick={onApprove} disabled={isProcessing} - className={cn("flex-1", (command || filePermission) && "rounded-r-none")} + className={cn("flex-1", hasScopeActions && "rounded-r-none")} > Approve - {(command || filePermission) && ( + {hasScopeActions && (

-

+

Persistent agents that fire on a schedule or in response to events. Toggle a task inactive to pause it.

-
+
+
{loading ? (
@@ -1845,16 +1824,17 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu {isRunning ? ( -
+
- Updating… + Updating
)} +
@@ -40,22 +43,15 @@ export function CaffeinateIndicator() { toast.error("Failed to turn off Caffeinate") }) }} - disabled={!enabled} - aria-hidden={!enabled} aria-label="Caffeinate is on — click to turn off" - className={cn( - "titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-amber-500 transition-colors self-center shrink-0", - enabled ? "hover:bg-accent hover:text-amber-600" : "invisible pointer-events-none", - )} + className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-amber-500 transition-colors self-center shrink-0 hover:bg-accent hover:text-amber-600" > - {enabled && ( - - Caffeinate is on — your Mac won't sleep. Click to turn off. - - )} + + Caffeinate is on — your Mac won't sleep. Click to turn off. + ) } diff --git a/apps/x/apps/renderer/src/components/chat-empty-state.tsx b/apps/x/apps/renderer/src/components/chat-empty-state.tsx index fca21600..3db196e0 100644 --- a/apps/x/apps/renderer/src/components/chat-empty-state.tsx +++ b/apps/x/apps/renderer/src/components/chat-empty-state.tsx @@ -1,4 +1,4 @@ -import { Bot, Mail, Sparkles, Telescope } from 'lucide-react' +import { ArrowRight, BookOpen, Mail, Zap } from 'lucide-react' import { cn } from '@/lib/utils' import { ToolConnectionsCard } from '@/components/tool-connections-card' @@ -12,53 +12,55 @@ interface ChatEmptyStateProps { const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [ { icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" }, - { icon: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' }, - { icon: Telescope, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' }, + { icon: Zap, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' }, + { icon: BookOpen, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' }, ] /** * Empty-state body for the chat surface: greeting and starter action cards. - * Shown in both the side-pane copilot and full-screen chat. + * Shown in both the side-pane copilot and full-screen chat; the side pane + * (`wide` unset) uses slightly smaller type to fit the narrow column. */ export function ChatEmptyState({ onPickPrompt, wide = false, }: ChatEmptyStateProps) { return ( -
-
-
- -
-
-
What are we working on?
-
Ask anything, or start with a suggestion.
-
-
- +
-
- Get started +
+ What are we working on?
-
- {SUGGESTED_ACTIONS.map((action) => ( - - ))} +
+ Ask anything, or start with a suggestion.
- +
+ {SUGGESTED_ACTIONS.map((action, i) => ( + + ))} +
+ +
) } diff --git a/apps/x/apps/renderer/src/components/chat-header.tsx b/apps/x/apps/renderer/src/components/chat-header.tsx index 0350f895..d100d339 100644 --- a/apps/x/apps/renderer/src/components/chat-header.tsx +++ b/apps/x/apps/renderer/src/components/chat-header.tsx @@ -1,8 +1,13 @@ -import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react' +import { useCallback } from 'react' +import { ArrowUpRight, Bug, ChevronDown, MessageSquare, MoreHorizontal, Plus } from 'lucide-react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TokenUsageMenu } from '@/components/token-usage-menu' +import type { TokenUsage } from '@/lib/chat-conversation' +import { hasTokenUsage } from '@/lib/token-usage' import { DropdownMenu, DropdownMenuContent, @@ -24,6 +29,7 @@ export interface ChatHeaderProps { onNewChatTab: () => void recentRuns?: ChatHeaderRecentRun[] activeRunId?: string | null + sessionUsage?: TokenUsage onSelectRun?: (runId: string) => void onOpenChatHistory?: () => void } @@ -40,10 +46,37 @@ export function ChatHeader({ onNewChatTab, recentRuns = [], activeRunId, + sessionUsage, onSelectRun, onOpenChatHistory, }: ChatHeaderProps) { const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory) + const showUsage = hasTokenUsage(sessionUsage) + + const handleDownloadChatLog = useCallback(async () => { + if (!activeRunId) { + toast.error('No chat log available yet') + return + } + try { + // Session-first (new runtime); legacy runs fallback covers old + // background tabs until stage 7 removes the runs runtime. + let result: { success: boolean; error?: string } + try { + result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId }) + } catch { + result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) + } + if (result.success) { + toast.success('Chat log saved') + } else if (result.error) { + toast.error(result.error) + } + } catch (err) { + console.error('Download chat log failed:', err) + toast.error('Failed to download chat log') + } + }, [activeRunId]) return ( <> @@ -95,6 +128,14 @@ export function ChatHeader({ {activeTitle}
)} + {showUsage && ( + + )} + + + Chat options + + + { + void handleDownloadChatLog() + }} + > + + Download chat log + + + ) } diff --git a/apps/x/apps/renderer/src/components/chat-history-view.tsx b/apps/x/apps/renderer/src/components/chat-history-view.tsx index 831a8aa1..b9a299f7 100644 --- a/apps/x/apps/renderer/src/components/chat-history-view.tsx +++ b/apps/x/apps/renderer/src/components/chat-history-view.tsx @@ -1,7 +1,8 @@ import { useCallback, useMemo, useState } from 'react' -import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react' +import { ExternalLink, MoreVertical, Pencil, SearchIcon, SquarePen, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' import { ContextMenu, ContextMenuContent, @@ -9,6 +10,13 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Dialog, DialogContent, @@ -33,6 +41,7 @@ type ChatHistoryViewProps = { processingRunIds?: Set onSelectRun: (runId: string) => void onOpenInNewTab?: (runId: string) => void + onRenameRun?: (runId: string, title: string) => void onDeleteRun: (runId: string) => Promise | void onNewChat?: () => void onOpenSearch?: () => void @@ -44,11 +53,14 @@ export function ChatHistoryView({ processingRunIds, onSelectRun, onOpenInNewTab, + onRenameRun, onDeleteRun, onNewChat, onOpenSearch, }: ChatHistoryViewProps) { const [pendingDeleteId, setPendingDeleteId] = useState(null) + const [renamingId, setRenamingId] = useState(null) + const [renameDraft, setRenameDraft] = useState('') const sortedRuns = useMemo(() => { return [...runs].sort((a, b) => { @@ -65,92 +77,188 @@ export function ChatHistoryView({ await onDeleteRun(id) }, [pendingDeleteId, onDeleteRun]) + const startRename = useCallback((run: Run) => { + setRenameDraft(run.title || '') + setRenamingId(run.id) + }, []) + + const commitRename = useCallback((runId: string) => { + const title = renameDraft.trim() + const current = runs.find((r) => r.id === runId) + setRenamingId(null) + if (!title || title === (current?.title ?? '')) return + onRenameRun?.(runId, title) + }, [renameDraft, runs, onRenameRun]) + return ( -
-
-

Chat history

-
- {onOpenSearch && ( - - )} - {onNewChat && ( - - )} +
+
+
+

Chat history

+
+ {onOpenSearch && ( + + )} + {onNewChat && ( + + )} +
+

+ {sortedRuns.length === 0 + ? 'Every conversation you have shows up here.' + : `${sortedRuns.length} ${sortedRuns.length === 1 ? 'conversation' : 'conversations'}, newest first.`} +

-
-
-
Title
-
Last modified
-
- +
{sortedRuns.length === 0 ? ( -
No chats yet.
+
+ No chats yet. +
) : ( - sortedRuns.map((run) => { - const isActive = currentRunId === run.id - const isProcessing = processingRunIds?.has(run.id) - return ( - - - - - - {onOpenInNewTab && ( - <> - onOpenInNewTab(run.id)}> - - Open in new tab - - - - )} - {!isProcessing && ( - setPendingDeleteId(run.id)} +
+
+
Title
+
Last modified
+
+
+ + {sortedRuns.map((run) => { + const isActive = currentRunId === run.id + const isProcessing = processingRunIds?.has(run.id) + return ( + + +
- - Delete - - )} - - - ) - }) + {renamingId === run.id ? ( +
+ setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + commitRename(run.id) + } else if (e.key === 'Escape') { + e.preventDefault() + setRenamingId(null) + } + }} + onBlur={() => commitRename(run.id)} + className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring" + /> +
+ ) : ( + <> + + + + + + + {onOpenInNewTab && ( + <> + onOpenInNewTab(run.id)}> + + Open in new tab + + + + )} + {onRenameRun && ( + startRename(run)}> + + Rename + + )} + {!isProcessing && ( + setPendingDeleteId(run.id)} + > + + Delete + + )} + + + + )} +
+
+ + {onOpenInNewTab && ( + <> + onOpenInNewTab(run.id)}> + + Open in new tab + + + + )} + {onRenameRun && ( + startRename(run)}> + + Rename + + )} + {!isProcessing && ( + setPendingDeleteId(run.id)} + > + + Delete + + )} + +
+ ) + })} +
)}
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 223bfac8..9e6d76b4 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, @@ -17,6 +17,7 @@ import { Globe, ImagePlus, LoaderIcon, + Brain, Lock, Mic, MoreHorizontal, @@ -37,6 +38,7 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, @@ -44,6 +46,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models' import { type AttachmentIconKind, getAttachmentDisplayName, @@ -100,6 +103,84 @@ interface ConfiguredModel { model: string } +// One picker group per connected provider. Catalog groups carry a resolved +// model list (models:list / saved config); live groups carry credentials and +// fetch their list from the provider inside the dropdown via +// useProviderModels (models:listForProvider). +const LIVE_PICKER_FLAVORS = new Set(['openrouter', 'aigateway', 'ollama', 'openai-compatible']) +// Catalog-preferred flavors that degrade to a live fetch when models:list has +// no catalog for them (signed-in mode returns only the rowboat provider, or +// the models.dev cache is empty). +const LIVE_FALLBACK_FLAVORS = new Set(['openai', 'anthropic', 'google']) + +type ModelPickerGroup = + | { kind: 'catalog'; flavor: string; models: string[] } + | { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string } + +// Rendered inside the dropdown's radio group: each live provider fetches its +// own list, so groups load and fail independently. Pinned models (the saved +// default / app default) render first — the model that actually runs is +// always pickable even while the fetch is pending or failed. Live-fetched +// ids carry no reasoning metadata, so the effort control stays hidden for +// them (reasoningByKey lookup misses default to off). +// +// The group owns its header so it can hide itself when the search filter +// matches none of its rows. Loading/error rows are status, not models — they +// render (with the header) regardless of the filter, and don't count toward +// the parent's "No models match" check (which is what gets reported up). +function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: { + group: Extract + label: string + pinnedModels: string[] + filter: string + onModelRowsChange: (flavor: string, hasModelRows: boolean) => void +}) { + const { status, models, error, refetch } = useProviderModels({ + flavor: group.flavor, + apiKey: group.apiKey, + baseURL: group.baseURL, + }) + const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))] + const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items + const showStatus = status === 'loading' || status === 'error' + const hasModelRows = visible.length > 0 + useEffect(() => { + onModelRowsChange(group.flavor, hasModelRows) + }, [group.flavor, hasModelRows, onModelRowsChange]) + if (!hasModelRows && !showStatus) return null + return ( + <> + {label} + {visible.map((m) => { + const key = `${group.flavor}/${m}` + return ( + + {m} + + ) + })} + {status === 'loading' && ( +
+ + Loading models… +
+ )} + {status === 'error' && ( + { + e.preventDefault() + refetch() + }} + className="text-xs" + > + {error || 'Failed to load models'} + Retry + + )} + + ) +} + type RecentWorkDir = { path: string lastUsedAt: number @@ -110,6 +191,16 @@ export interface SelectedModel { model: string } +export type ReasoningEffortLevel = 'low' | 'medium' | 'high' + +// '' = auto (provider default). Ordered as shown in the picker. +const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [ + { value: '', label: 'Auto', hint: 'Provider default' }, + { value: 'low', label: 'Fast', hint: 'Minimal thinking' }, + { value: 'medium', label: 'Balanced', hint: 'Moderate thinking' }, + { value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' }, +] + export type PermissionMode = 'manual' | 'auto' function getSelectedModelDisplayName(model: string) { @@ -254,6 +345,11 @@ interface ChatInputInnerProps { callAvailable?: boolean /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ onSelectedModelChange?: (model: SelectedModel | null) => void + /** + * Fired when the user picks a reasoning effort (null = auto). Unlike model, + * effort is never frozen on a run — it applies per turn. + */ + onReasoningEffortChange?: (effort: ReasoningEffortLevel | null) => void /** Work directory for this chat (per-chat). Null when none is set. */ workDir?: string | null /** Fired when the user sets/changes/clears the work directory for this chat. */ @@ -290,6 +386,7 @@ function ChatInputInner({ onEndCall, callAvailable, onSelectedModelChange, + onReasoningEffortChange, workDir = null, onWorkDirChange, codeSessionLock = null, @@ -301,7 +398,7 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing - const [configuredModels, setConfiguredModels] = useState([]) + const [modelGroups, setModelGroups] = useState([]) 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 @@ -309,6 +406,10 @@ function ChatInputInner({ const [defaultModel, setDefaultModel] = useState(null) const loadModelConfigEpoch = useRef(0) const [lockedModel, setLockedModel] = useState(null) + // '' = auto. Per-model reasoning capability ("provider/model" → flag) from + // models:list; the effort control renders only for known-reasoning models. + const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('') + const [reasoningByKey, setReasoningByKey] = useState>({}) const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) @@ -417,73 +518,93 @@ function ChatInputInner({ if (loadModelConfigEpoch.current === epoch) setDefaultModel(null) } try { - const models: ConfiguredModel[] = [] - const seen = new Set() - 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 }) - } + const groups: ModelPickerGroup[] = [] - // Full catalog per provider (gateway + cloud). Providers with no - // catalog (Ollama, OpenAI-compatible) fall back to the models saved in - // config below. + // Full catalog per provider (gateway + models.dev cloud providers). const catalog: Record = {} + const reasoningFlags: Record = {} 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) + for (const m of p.models || []) { + if (typeof m.reasoning === 'boolean') { + reasoningFlags[`${p.id}/${m.id}`] = m.reasoning + } + } } - } catch { /* offline / no catalog — fall back to saved config below */ } + } catch { /* offline / no catalog — groups fall back to saved config below */ } + if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags) - if (isRowboatConnected) { - for (const m of catalog['rowboat'] || []) push('rowboat', m) + if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) { + groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] }) } try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) - // List the default provider first so its default model leads the - // BYOK section of the picker. + // List the default provider's group first. 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 - 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 + const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : '' + const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : '' + if (!apiKey && !baseURL) continue // provider not configured + const savedModel = typeof e.model === 'string' ? e.model : '' - // The provider's saved default model leads, then the rest of its catalog. - push(flavor, typeof e.model === 'string' ? e.model : '') + // Live flavors fetch their list from the provider inside the + // dropdown, with the credentials saved in config. Catalog flavors + // degrade to the same live fetch when models:list carried no + // catalog for them (signed in, or empty models.dev cache). 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 (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) { + groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel }) + continue } + + // Catalog group: the saved default model leads, then the catalog. + // Saved models[] survives as the fallback for unknown flavors the + // live fetch doesn't support. + const models: string[] = [] + const push = (model: string) => { + if (model && !models.includes(model)) models.push(model) + } + push(savedModel) + if (catalogModels.length > 0) { + for (const m of catalogModels) push(m) + } else { + const saved = Array.isArray(e.models) ? e.models as string[] : [] + for (const m of saved) push(m) + } + groups.push({ kind: 'catalog', flavor, models }) } - // The user's explicit default selection leads the whole picker. + // The user's explicit default selection leads the picker: its group + // first and, within a catalog group, the model itself first. (Live + // groups pin the default at the top themselves.) const sel = parsed?.defaultSelection if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') { - const selKey = `${sel.provider}/${sel.model}` - const index = models.findIndex((m) => `${m.provider}/${m.model}` === selKey) - if (index > 0) { - const [entry] = models.splice(index, 1) - models.unshift(entry) + const index = groups.findIndex((g) => g.flavor === sel.provider) + if (index >= 0) { + const [group] = groups.splice(index, 1) + groups.unshift(group) + if (group.kind === 'catalog') { + const mi = group.models.indexOf(sel.model) + if (mi > 0) { + group.models.splice(mi, 1) + group.models.unshift(sel.model) + } + } } } } catch { /* no BYOK config yet */ } if (loadModelConfigEpoch.current !== epoch) return - setConfiguredModels(models) + setModelGroups(groups) } catch (err) { // No config yet — but surface unexpected failures for diagnosis. console.error('[chat-input] failed to load model list', err) @@ -673,25 +794,82 @@ 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(() => { - 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]) + // Search filter for the model dropdown. Reset each time the menu opens; + // matching is a case-insensitive substring test on the model id. Live + // groups filter themselves and report whether they still have rows, so the + // parent can render the global "No models match" row. + const [modelFilter, setModelFilter] = useState('') + const modelFilterInputRef = useRef(null) + const [liveGroupHasRows, setLiveGroupHasRows] = useState>({}) + const modelFilterValue = modelFilter.trim().toLowerCase() + const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => { + setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows })) + }, []) - // 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. + // The effective default always renders even when no group carries it (the + // gateway list failed, or its provider was removed from config) — the + // picker must never be missing the model that actually runs. Live groups + // pin the default themselves, so a flavor match is enough there. + const standaloneDefault = useMemo(() => { + if (!defaultModel) return null + const covered = modelGroups.some((g) => + g.flavor === defaultModel.provider && + (g.kind === 'live' || g.models.includes(defaultModel.model))) + return covered ? null : defaultModel + }, [modelGroups, defaultModel]) + + const standaloneVisible = standaloneDefault !== null && + (!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue)) + // Nothing matches anywhere → "No models match". Live groups that haven't + // reported yet (first render after opening) count as having rows so the + // empty row never flashes. + const anyModelRowVisible = standaloneVisible || modelGroups.some((g) => + g.kind === 'catalog' + ? g.models.some((m) => m.toLowerCase().includes(modelFilterValue)) + : liveGroupHasRows[g.flavor] !== false) + + // Selecting a model affects the *next* run created from this tab (frozen + // once a run exists) AND persists as the app default so background agents + // and new tabs follow the last pick. The models-config-changed dispatch + // re-runs loadModelConfig here too — that re-read lands on the same + // selection (activeModelKey is untouched, the live lists come from the + // hook's cache), so it's visually a no-op. const handleModelChange = useCallback((key: string) => { if (lockedModel) return - const entry = pickerModels.find((m) => `${m.provider}/${m.model}` === key) - if (!entry) return + const slash = key.indexOf('/') + if (slash <= 0 || slash === key.length - 1) return + const provider = key.slice(0, slash) + const model = key.slice(slash + 1) setActiveModelKey(key) - onSelectedModelChange?.({ provider: entry.provider, model: entry.model }) - }, [pickerModels, lockedModel, onSelectedModelChange]) + onSelectedModelChange?.({ provider, model }) + void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } }) + .then(() => { window.dispatchEvent(new Event('models-config-changed')) }) + .catch(() => { toast.error('Failed to save default model') }) + }, [lockedModel, onSelectedModelChange]) + + // Reasoning effort applies to the model the next message will actually use: + // the run's frozen model once one exists, else the picker selection, else + // the app default. Only known-reasoning models show the control. + const effectiveModelKey = lockedModel + ? `${lockedModel.provider}/${lockedModel.model}` + : activeModelKey + || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '') + const reasoningAvailable = reasoningByKey[effectiveModelKey] === true + + const handleReasoningEffortChange = useCallback((value: string) => { + const effort = value === 'low' || value === 'medium' || value === 'high' ? value : '' + setReasoningEffort(effort) + onReasoningEffortChange?.(effort === '' ? null : effort) + }, [onReasoningEffortChange]) + + // Switching to a model without reasoning support drops a stale selection — + // otherwise the next message would carry an effort the model rejects. + useEffect(() => { + if (!reasoningAvailable && reasoningEffort !== '') { + setReasoningEffort('') + onReasoningEffortChange?.(null) + } + }, [reasoningAvailable, reasoningEffort, onReasoningEffortChange]) // Restore the tab draft when this input mounts. useEffect(() => { @@ -1284,6 +1462,37 @@ function ChatInputInner({ )}
+ {reasoningAvailable && ( + + + + + + + + Reasoning effort — applies to your next message + + + + {REASONING_EFFORT_OPTIONS.map((option) => ( + + {option.label} + {option.hint} + + ))} + + + + )} {lockedModel ? ( @@ -1295,8 +1504,19 @@ function ChatInputInner({ {providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat - ) : pickerModels.length > 0 ? ( - + ) : ( + { + // The filter is per-opening, never sticky. Focus the search + // input once the content has mounted and Radix has run its own + // open-focus (DropdownMenu.Content has no onOpenAutoFocus). + if (open) { + setModelFilter('') + setLiveGroupHasRows({}) + setTimeout(() => modelFilterInputRef.current?.focus(), 0) + } + }} + > - - - {pickerModels.map((m) => { - const key = `${m.provider}/${m.model}` - return ( - - {m.model} - {providerDisplayNames[m.provider] || m.provider} - - ) - })} - + + {modelGroups.length === 0 && !standaloneDefault ? ( +
+ Connect a provider in Settings +
+ ) : ( + <> + {/* Fixed search header — lives OUTSIDE the scroll area (the + inner div below scrolls), so it's flush at the very top + and always visible without any scroll. */} +
+ setModelFilter(e.target.value)} + onKeyDown={(e) => { + // Printable keys belong to the input, not the menu's + // typeahead; arrows and Escape stay with the menu. + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') { + e.stopPropagation() + } + }} + placeholder="Search models…" + className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground" + /> +
+
+ + {standaloneDefault && standaloneVisible && ( + + {standaloneDefault.model} + + {providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider} + + + )} + {modelGroups.map((g) => { + const label = providerDisplayNames[g.flavor] || g.flavor + if (g.kind === 'live') { + // The app default leads its live group; the group's + // own saved model follows (both stay pickable through + // fetch loading/failure). + const pinned: string[] = [] + if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model) + if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel) + return ( + + ) + } + const visibleModels = modelFilterValue + ? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue)) + : g.models + if (visibleModels.length === 0) return null + return ( + + + {label} + + {visibleModels.map((m) => { + const key = `${g.flavor}/${m}` + return ( + + {m} + + ) + })} + + ) + })} + {modelFilterValue && !anyModelRowVisible && ( +
No models match
+ )} +
+
+ + )}
- ) : null} + )} {onStartCall && (
@@ -1570,6 +1860,7 @@ export interface ChatInputWithMentionsProps { onEndCall?: () => void callAvailable?: boolean onSelectedModelChange?: (model: SelectedModel | null) => void + onReasoningEffortChange?: (effort: ReasoningEffortLevel | null) => void workDir?: string | null onWorkDirChange?: (value: string | null) => void /** Set when this chat is bound to a Code-section session — freezes workdir + agent. */ @@ -1603,6 +1894,7 @@ export function ChatInputWithMentions({ onEndCall, callAvailable, onSelectedModelChange, + onReasoningEffortChange, workDir, onWorkDirChange, codeSessionLock, @@ -1633,6 +1925,7 @@ export function ChatInputWithMentions({ onEndCall={onEndCall} callAvailable={callAvailable} onSelectedModelChange={onSelectedModelChange} + onReasoningEffortChange={onReasoningEffortChange} workDir={workDir} onWorkDirChange={onWorkDirChange} codeSessionLock={codeSessionLock} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 63c3a90a..b3d5850c 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,18 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react' -import { toast } from 'sonner' +import { ArrowLeft, ArrowRight, Pin } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ChatHeader } from '@/components/chat-header' import { ChatEmptyState } from '@/components/chat-empty-state' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { Conversation, ConversationContent, @@ -21,6 +14,7 @@ import { import { Message, MessageContent, + MessageCopyButton, MessageResponse, } from '@/components/ai-elements/message' import { TurnActivityIndicator } from '@/components/turn-activity-indicator' @@ -37,7 +31,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 CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel, type ReasoningEffortLevel } 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' @@ -47,6 +41,7 @@ import { type ChatTabViewState, type ConversationItem, type PermissionResponse, + type TokenUsage, createEmptyChatTabViewState, getWebSearchCardData, getComposioConnectCardData, @@ -57,12 +52,15 @@ import { isErrorMessage, isToolCall, isToolGroup, + isTurnUsageMessage, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, + REASONING_EFFORT_LABELS, toToolState, } from '@/lib/chat-conversation' import { matchBillingError } from '@/lib/billing-error' +import { TokenUsageMenu } from '@/components/token-usage-menu' const streamdownComponents = { pre: MarkdownPreOverride } @@ -140,6 +138,7 @@ interface ChatSidebarProps { onOpenFullScreen?: () => void conversation: ConversationItem[] currentAssistantMessage: string + sessionUsage?: TokenUsage chatTabStates?: Record viewportAnchors?: Record isProcessing: boolean @@ -157,6 +156,7 @@ interface ChatSidebarProps { getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void + onReasoningEffortChangeForTab?: (tabId: string, effort: ReasoningEffortLevel | null) => void workDirByTab?: Record /** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */ codeSessionLocks?: Record @@ -170,7 +170,7 @@ interface ChatSidebarProps { allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions'] - onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void + onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse) => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void isToolOpenForTab?: (tabId: string, toolId: string) => boolean onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void @@ -210,6 +210,7 @@ export function ChatSidebar({ onOpenFullScreen, conversation, currentAssistantMessage, + sessionUsage = {}, chatTabStates = {}, viewportAnchors = {}, isProcessing, @@ -227,6 +228,7 @@ export function ChatSidebar({ getInitialDraft, onDraftChangeForTab, onSelectedModelChangeForTab, + onReasoningEffortChangeForTab, workDirByTab = {}, codeSessionLocks = {}, pinnedToCodeSession = null, @@ -350,6 +352,7 @@ export function ChatSidebar({ runId: runId ?? null, conversation, currentAssistantMessage, + sessionUsage, pendingAskHumanRequests, allPermissionRequests, permissionResponses, @@ -358,6 +361,7 @@ export function ChatSidebar({ runId, conversation, currentAssistantMessage, + sessionUsage, pendingAskHumanRequests, allPermissionRequests, permissionResponses, @@ -368,33 +372,6 @@ export function ChatSidebar({ if (tabId === activeChatTabId) return activeTabState return chatTabStates[tabId] ?? emptyTabState }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) - const activeRunId = activeTabState.runId - const handleDownloadChatLog = useCallback(async () => { - if (!activeRunId) { - toast.error('No chat log available yet') - return - } - - try { - // Session-first (new runtime); legacy runs fallback covers old - // background tabs until stage 7 removes the runs runtime. - let result: { success: boolean; error?: string } - try { - result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId }) - } catch { - result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) - } - if (result.success) { - toast.success('Chat log saved') - } else if (result.error) { - toast.error(result.error) - } - } catch (err) { - console.error('Download chat log failed:', err) - toast.error('Failed to download chat log') - } - }, [activeRunId]) - const renderConversationItem = ( item: ConversationItem, tabId: string, @@ -409,14 +386,17 @@ export function ChatSidebar({ {item.content && ( - - - {item.content} - - +
+ + + {item.content} + + + +
)} ) @@ -424,26 +404,29 @@ export function ChatSidebar({ const { message, files } = parseAttachedFiles(item.content) return ( - - {files.length > 0 && ( -
- {files.map((filePath, index) => ( - - @{wikiLabel(filePath)} - - ))} -
- )} - - {message} - -
+
+ + {files.length > 0 && ( +
+ {files.map((filePath, index) => ( + + @{wikiLabel(filePath)} + + ))} +
+ )} + + {message} + +
+ +
) } @@ -508,6 +491,24 @@ export function ChatSidebar({ ) } + if (isTurnUsageMessage(item)) { + return ( +
+ + {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )} +
+ ) + } + if (isErrorMessage(item)) { if (matchBillingError(item.message)) { return null @@ -600,38 +601,11 @@ export function ChatSidebar({ onNewChatTab={onNewChatTab} recentRuns={recentRuns} activeRunId={runId} + sessionUsage={activeTabState.sessionUsage} onSelectRun={onSelectRun} onOpenChatHistory={onOpenChatHistory} /> )} - - - - - - - - Chat options - - - { - void handleDownloadChatLog() - }} - > - - Download chat log - - - {onOpenFullScreen && ( @@ -732,8 +706,6 @@ export function ChatSidebar({ toolCall={permRequest.toolCall} permission={permRequest.permission} onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} isProcessing={isActive && isProcessing && !isWaitingOnHuman} response={response} @@ -812,6 +784,7 @@ export function ChatSidebar({ initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} + onReasoningEffortChange={onReasoningEffortChangeForTab ? (effort) => onReasoningEffortChangeForTab(tab.id, effort) : undefined} workDir={workDirByTab[tab.id] ?? null} onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined} codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null} diff --git a/apps/x/apps/renderer/src/components/compact-conversation.tsx b/apps/x/apps/renderer/src/components/compact-conversation.tsx index e14b7457..3224b14a 100644 --- a/apps/x/apps/renderer/src/components/compact-conversation.tsx +++ b/apps/x/apps/renderer/src/components/compact-conversation.tsx @@ -6,12 +6,15 @@ import { isChatMessage, isErrorMessage, isToolCall, + isTurnUsageMessage, getToolDisplayName, getToolErrorText, + REASONING_EFFORT_LABELS, toToolState, normalizeToolOutput, } from '@/lib/chat-conversation' import { Tool, ToolHeader, ToolContent, ToolTabbedContent } from '@/components/ai-elements/tool' +import { TokenUsageMenu } from '@/components/token-usage-menu' /** * Compact rendering of a run's conversation log — used by the live-note panel's @@ -34,6 +37,24 @@ export function CompactConversation({ items }: { items: ConversationItem[] }) {
) } + if (isTurnUsageMessage(item)) { + return ( +
+ + {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )} +
+ ) + } if (isToolCall(item)) return if (isChatMessage(item)) { const isUser = item.role === 'user' diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index f1b9ff17..8301ac55 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react' +import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, SlidersHorizontal, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -7,11 +7,19 @@ import Placeholder from '@tiptap/extension-placeholder' import type { blocks } from '@x/shared' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' +import { prepareEmailHtml, splitPlainTextQuote, stripQuotedReplyText, QUOTED_CLASS } from '@/lib/email-quotes' import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' type GmailThread = blocks.GmailThread type GmailThreadMessage = blocks.GmailThreadMessage @@ -72,31 +80,6 @@ function snippet(text?: string): string { return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) } -function isReplyQuoteBoundary(lines: string[], index: number): boolean { - const line = lines[index]?.trim() || '' - if (/^On\b.+\bwrote:\s*$/i.test(line)) return true - if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true - if (/^From:\s+\S/i.test(line)) { - const next = lines.slice(index + 1, index + 6).map((value) => value.trim()) - return next.some((value) => /^(Sent|Date):\s+\S/i.test(value)) - && next.some((value) => /^To:\s+\S/i.test(value)) - && next.some((value) => /^Subject:\s+\S/i.test(value)) - } - return false -} - -function stripQuotedReplyText(text: string): string { - const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') - const boundary = lines.findIndex((line, index) => { - if (isReplyQuoteBoundary(lines, index)) return true - return index > 0 - && line.trim().startsWith('>') - && (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>')) - }) - const visible = boundary >= 0 ? lines.slice(0, boundary) : lines - return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() -} - function getInitial(from?: string): string { return (extractName(from)[0] || '?').toUpperCase() } @@ -114,6 +97,70 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined { return thread.messages[thread.messages.length - 1] } +// The label set (chips, filter pills, correction dropdown) comes from the +// backend label registry: built-ins (display names follow Superhuman's Auto +// Label vocabulary — Marketing / Pitch / News / Calendar) plus any labels the +// user defined in their agent instructions. This static copy of the built-ins +// is the fallback used until the registry loads, and for ids the registry no +// longer knows (a custom label the user later removed). +type EmailCategory = string + +interface EmailLabelInfo { + id: string + name: string + kind: 'builtin' | 'custom' +} + +const BUILTIN_LABELS: EmailLabelInfo[] = [ + { id: 'correspondence', name: 'Direct', kind: 'builtin' }, + { id: 'meeting', name: 'Calendar', kind: 'builtin' }, + { id: 'notification', name: 'Notification', kind: 'builtin' }, + { id: 'newsletter', name: 'News', kind: 'builtin' }, + { id: 'promotion', name: 'Marketing', kind: 'builtin' }, + { id: 'cold_outreach', name: 'Pitch', kind: 'builtin' }, + { id: 'receipt', name: 'Receipt', kind: 'builtin' }, +] + +// Pill order in the "Everything else" filter row: noise first (that's what +// gets bulk-archived), then the rest of the built-ins; custom labels are +// appended in registry order at render time. 'unclassified' (threads the +// classifier hasn't reached yet) is always last and unarchivable. +const BUILTIN_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence'] + +// Fallback for ids the registry doesn't know: "portfolio_updates" → "Portfolio updates". +function prettifyLabelId(id: string): string { + const words = id.replace(/_/g, ' ').trim() + return words ? words[0].toUpperCase() + words.slice(1) : id +} + +function labelNameFor(labels: EmailLabelInfo[], category: string): string { + if (category === 'unclassified') return 'Uncategorized' + return labels.find((l) => l.id === category)?.name ?? prettifyLabelId(category) +} + +// The user sent the latest message and someone else is in the conversation — +// the ball is in their court. Derived from the messages on every render (never +// stored) so it can't go stale the way an arrival-time label would. +function isAwaitingThem(thread: GmailThread, selfEmail: string | null | undefined): boolean { + const self = (selfEmail || '').trim().toLowerCase() + if (!self) return false + const latest = latestMessage(thread) + if (!(latest?.from || '').toLowerCase().includes(self)) return false + return thread.messages.some((m) => m.from && !m.from.toLowerCase().includes(self)) +} + +function daysSince(value?: string): number | null { + if (!value) return null + const ms = Date.parse(value) + if (!Number.isFinite(ms)) return null + return Math.max(0, Math.floor((Date.now() - ms) / 86_400_000)) +} + +function waitingChip(thread: GmailThread): string { + const days = daysSince(latestMessage(thread)?.date || thread.date) + return days && days > 0 ? `Waiting ${days}d` : 'Waiting' +} + // Split a raw header recipient string (e.g. `"Jo Bloggs" , b@y.com`) into // individual address tokens, respecting commas inside quotes/angle brackets. function splitAddresses(raw?: string): string[] { @@ -307,43 +354,6 @@ function plainTextToHtml(text: string): string { .join('') } -function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } { - const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/ - const match = re.exec(text) - if (!match) return { visible: text, quoted: null } - const start = match.index === 0 ? 0 : match.index + 1 - const visible = text.slice(0, start).trimEnd() - const quoted = text.slice(start) - if (!quoted.trim()) return { visible: text, quoted: null } - return { visible, quoted } -} - -// True if the HTML — after stripping quoted/hidden content — defines its -// own visual layout (real images, tables, explicit backgrounds). Unstyled -// HTML (Gmail replies, Outlook one-liners wrapped in MsoNormal boilerplate, -// outreach emails with only a tracking pixel, reply HTML whose only image -// lives inside the inline-quoted thread) gets an iframe that adapts to the -// app theme; styled HTML keeps the white "paper" look so newsletters / -// branded designs render as their senders intended. -function isStyledHtml(html: string): boolean { - const doc = new DOMParser().parseFromString(html, 'text/html') - doc.querySelectorAll('.gmail_quote, .gmail_attr, blockquote[type="cite"]').forEach((n) => n.remove()) - if (doc.querySelector('table')) return true - for (const img of Array.from(doc.querySelectorAll('img'))) { - const w = parseInt(img.getAttribute('width') || '0', 10) - const h = parseInt(img.getAttribute('height') || '0', 10) - if (w === 1 && h === 1) continue - const style = img.getAttribute('style') || '' - if (/display\s*:\s*none/i.test(style)) continue - if (/visibility\s*:\s*hidden/i.test(style)) continue - return true - } - const visible = doc.body?.innerHTML || '' - if (/bgcolor\s*=/i.test(visible)) return true - if (/background-(color|image)\s*:/i.test(visible)) return true - return false -} - function buildEmailDocument( html: string, opts: { theme: 'light' | 'dark'; adaptToTheme: boolean }, @@ -373,6 +383,10 @@ function buildEmailDocument( overflow-y: hidden; word-wrap: break-word; padding-bottom: 4px; + /* Contain the first child's top margin. Without this it collapses through + , shifting the box down while body.scrollHeight stays short — so + the height we hand the iframe cuts the last line off. */ + display: flow-root; } body > *:last-child { margin-bottom: 0; } img { max-width: 100%; height: auto; } @@ -384,12 +398,8 @@ function buildEmailDocument( border-left: 2px solid ${quoteBorder}; color: ${quoteColor}; } - .gmail_quote, - .gmail_attr, - blockquote[type="cite"] { display: none; } - [data-show-quotes="true"] .gmail_quote, - [data-show-quotes="true"] .gmail_attr, - [data-show-quotes="true"] blockquote[type="cite"] { display: block; } + .${QUOTED_CLASS} { display: none; } + [data-show-quotes="true"] .${QUOTED_CLASS} { display: revert; } ${html}` } @@ -436,24 +446,25 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t const saveTimerRef = useRef | null>(null) const lastSavedHeightRef = useRef(message.bodyHeight ?? 0) const [height, setHeight] = useState(message.bodyHeight ?? 80) - const [hasQuote, setHasQuote] = useState(false) const [showQuotes, setShowQuotes] = useState(false) // Read by handleLoad so a reload (theme switch rebuilds srcDoc) restores the // expanded-quotes state on the fresh document. const showQuotesRef = useRef(showQuotes) useEffect(() => { showQuotesRef.current = showQuotes }, [showQuotes]) - const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml]) + // Tag the quotes before the iframe ever paints, so quoted history is hidden + // on the first frame and the height we measure is the collapsed height. + const { html, hasQuote, styled } = useMemo(() => prepareEmailHtml(message.bodyHtml!), [message.bodyHtml]) + const adaptToTheme = !styled const srcDoc = useMemo( - () => buildEmailDocument(message.bodyHtml!, { theme: resolvedTheme, adaptToTheme }), - [message.bodyHtml, resolvedTheme, adaptToTheme], + () => buildEmailDocument(html, { theme: resolvedTheme, adaptToTheme }), + [html, resolvedTheme, adaptToTheme], ) const handleLoad = useCallback(() => { const iframe = iframeRef.current const doc = iframe?.contentDocument if (!doc?.body) return - setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]')) if (showQuotesRef.current) doc.documentElement.dataset.showQuotes = 'true' // Clicking into the email body focuses the iframe document, which would // otherwise swallow every list/thread shortcut (the parent's document @@ -1304,6 +1315,15 @@ const ComposeBox = memo(function ComposeBox({ setGenerating(true) try { + // The user's standing email-agent instructions steer this writer too — + // otherwise "keep drafts short, sign off with X" would apply to the + // auto-drafted replies but silently not to the Write-with-AI bar. + try { + const { instructions } = await window.ipc.invoke('gmail:getEmailInstructions', {}) + if (instructions.trim()) { + system = `${system}\n\nThe user's standing email preferences — follow any that concern how emails should be written (tone, length, sign-off, what not to write):\n${instructions.trim()}` + } + } catch { /* draft without them */ } // Draft through Copilot: no model override, so the backend resolves the // same default model/provider the Copilot chat uses (models.json). const res = await window.ipc.invoke('llm:generate', { prompt, system }) @@ -1970,6 +1990,8 @@ function ThreadDetail({ hidden, keysDisabled, onComposingChange, + onSetCategory, + labels = BUILTIN_LABELS, }: { thread: GmailThread onClose: () => void @@ -1978,6 +2000,10 @@ function ThreadDetail({ keysDisabled?: boolean /** Reports whether the inline composer is open, so list shortcuts pause. */ onComposingChange?: (composing: boolean) => void + /** Present for inbox threads only — search results aren't in the cache the correction writes to. */ + onSetCategory?: (threadId: string, category: EmailCategory) => Promise + /** The label registry (built-ins + user-defined) for the chip and correction dropdown. */ + labels?: EmailLabelInfo[] }) { const [composeMode, setComposeMode] = useState(null) const [selfEmail, setSelfEmail] = useState('') @@ -2059,6 +2085,30 @@ function ThreadDetail({