Merge branch 'main' into client-auto-update-ux

This commit is contained in:
Harshvardhan Vatsa 2026-07-14 21:57:38 +05:30 committed by GitHub
commit 67d6a542d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
298 changed files with 15859 additions and 6388 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,3 +1,7 @@
<a href="https://www.youtube.com/watch?v=5AWoGo-L16I" target="_blank" rel="noopener noreferrer">
<img width="1339" height="607" alt="rowboat-github-2" src="assets/readme-dark/hero-video.png" />
</a>
<h5 align="center">
<h1 align="center">Rowboat</h1>
@ -26,14 +30,14 @@
</h5>
Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects.
Rowboat 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)
<p align="center">
<a href="https://www.youtube.com/watch?v=et5yQABJ3xI">
<img width="1091" height="632" alt="Rowboat demo video" src="https://github.com/user-attachments/assets/440087ae-5674-427a-87bb-831b0bea7de4" />
<img width="800" height="450" alt="Rowboat Apps to Code demo" src="apps/x/demo.gif" />
</a>
</p>
@ -51,10 +55,10 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do
<tr>
<td width="40%" valign="middle">
<h3>Brain</h3>
Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
</td>
<td width="60%">
<img width="1499" height="940" alt="Screenshot 2026-06-24 at 11 22 52PM" src="https://github.com/user-attachments/assets/203ca1d5-2f40-43dd-8da7-c5cef4e31a41" />
<img width="1502" height="939" alt="Brain graph screenshot" src="assets/readme-dark/brain.png" />
</td>
</tr>
<tr>
@ -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.
</td>
<td width="60%">
<img width="1512" height="948" alt="Email screenshot" src="https://github.com/user-attachments/assets/4392b3ca-cc4c-473a-849a-eea0e97388f2" />
<img width="1512" height="948" alt="Email screenshot" src="assets/readme-dark/email.png" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Background agents</h3>
You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex.
You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex.
</td>
<td width="60%">
<img width="1512" height="951" alt="Background agents screenshot" src="https://github.com/user-attachments/assets/5b73a3c3-f0d3-4151-83e7-0997457074e6" />
<img width="1512" height="951" alt="Background agents screenshot" src="assets/readme-dark/background-agents.png" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Built-in Browser</h3>
Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because 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.
</td>
<td width="60%">
<img width="1512" height="948" alt="Browser screenshot" src="https://github.com/user-attachments/assets/ce04871f-4477-40eb-8310-13ef7f125b11" />
<img width="1512" height="948" alt="Browser screenshot" src="assets/readme-dark/browser.png" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Meeting Notes</h3>
A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph.
A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph.
</td>
<td width="60%">
<img width="1512" height="947" alt="Meeting notes screenshot" src="https://github.com/user-attachments/assets/c3729952-3c75-4c84-88e0-2a9070136502" />
<img width="1512" height="947" alt="Meeting notes screenshot" src="assets/readme-dark/meeting-notes.png" />
</td>
</tr>
<tr>
@ -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.
</td>
<td width="60%">
<img width="1512" height="949" alt="Code mode screenshot" src="https://github.com/user-attachments/assets/306618b5-9aaf-4ef8-9117-91ea58e5e4e7" />
<img width="1512" height="949" alt="Code mode screenshot" src="assets/readme-dark/code-mode.png" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Apps</h3>
You can 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.
</td>
<td width="60%">
<img width="1512" height="949" alt="Apps screenshot" src="https://github.com/user-attachments/assets/aaedc79b-3e72-45e8-995c-e8a3d127fdac" />
<img width="1512" height="949" alt="Apps screenshot" src="assets/readme-dark/apps.png" />
</td>
</tr>
<tr>
<td width="40%" valign="middle">
<h3>Integrations</h3>
Includes one-click integrations to most popular products.
Includes one-click integrations to most popular products.
</td>
<td width="60%">
<img width="1512" height="948" alt="Integrations screenshot" src="https://github.com/user-attachments/assets/402e89db-8229-468a-8881-a763b9f20ad9" />
<img width="1512" height="948" alt="Integrations screenshot" src="assets/readme-dark/integrations.png" />
</td>
</tr>

View file

@ -12,4 +12,4 @@ o file logging
rowboat agent
---
- create agent
- create agent

View file

@ -55,6 +55,6 @@
"chatgpt",
"claude"
]
}
}
}

View file

@ -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.
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.

View file

@ -4,4 +4,4 @@ icon: "road"
# Roadmap
Explore the future development plans and upcoming features for Rowboat.
Explore the future development plans and upcoming features for Rowboat.

View file

@ -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) |

153
apps/x/GRANOLA_PARITY.md Normal file
View file

@ -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/<date>/<name>.md` (`:93-136`, `:487-510`) |
| AI meeting notes on stop | ✅ | `packages/core/src/knowledge/summarize_meeting.ts` — LLM summary, attendee-name resolution from calendar; orchestrated in `App.tsx:5601-5661`; notes prepended above transcript |
| Auto-stop heuristics | ✅ (partial) | silence RMS backstop, calendar-end gating, system-track ended/muted — `useMeetingTranscription.ts:378-549` |
| Calendar sync | ✅ | Google OAuth via `googleapis`, `packages/core/src/knowledge/sync_calendar.ts`, per-event JSON in `~/.rowboat/calendar_sync/` |
| Pre-meeting notification | ✅ (system toast) | `packages/core/src/knowledge/notify_calendar_meetings.ts` — polls every 30s, notifies ~1 min before, deep-links `rowboat://action?type=join-and-take-meeting-notes` |
| Meetings screen | ✅ | `apps/renderer/src/components/meetings-view.tsx` — "Coming up" (+Join / Take-notes buttons, inline prep) + past-notes table |
| Transcript rendering | ✅ | `apps/renderer/src/extensions/transcript-block.tsx` (TipTap, colored speakers, collapsible) |
| Meeting prep briefs | ✅ | `meeting_prep_scheduler.ts`, `meeting_prep_brief.ts` (Granola has this too) |
| Permissions flows | ✅ | mic `ipc.ts:2090-2106`; screen recording check/open-settings `ipc.ts:2003-2018`; Info.plist strings `forge.config.cjs:201-204`; entitlements OK |
### What is MISSING (the entire "ambient" layer)
| # | Gap | Detail |
|---|---|---|
| 1 | **Launch at login** | Zero `setLoginItemSettings` / auto-launch code anywhere. |
| 2 | **Menu bar (tray) icon** | Zero `Tray` usage. No background presence: on macOS closing the window leaves only the Dock; no way to start recording without the full window. |
| 3 | **Meeting detection** | No mic-in-use detection, no process detection (Zoom/Teams/Slack/FaceTime), no browser awareness. Only calendar (time-based). Ad-hoc calls are invisible. |
| 4 | **Auto/one-click start UX** | Capture starts only via explicit button click or clicking the calendar toast. No "Meeting detected — Take notes?" popup, no armed auto-start when a note is open at meeting start time. |
| 5 | **Headless capture** | Capture depends on the renderer window holding a live `getDisplayMedia` stream. No native audio helper → can't capture while the app is "in the background" the way Granola does. |
| 6 | **Meeting-end redirect** | On stop, the note refreshes in place, but there's **no "notes ready" notification** and no bring-the-user-back moment if they're in another app. |
| 7 | **Notes UI polish (Granola signatures)** | No black-vs-grey authorship distinction (we replace, they merge raw notes + transcript), no during-meeting notepad-first flow (we show the transcript note), no per-line provenance, no templates/re-enhance, no floating "recording" pill. |
### Honest assessment
Rowboat has built the *second half* of Granola well — what happens once you're recording, and after the meeting ends. It has essentially none of the *first half* — noticing a meeting is happening and quietly being there without the user opening the app. That first half is exactly items 15 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 14 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`.

View file

@ -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` |

View file

@ -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

View file

@ -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

View file

@ -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<AudioDeviceID>.size)
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &deviceID)
guard status == noErr, deviceID != kAudioObjectUnknown else { return nil }
return deviceID
}
func isRunningSomewhere(_ device: AudioDeviceID) -> Bool {
var running = UInt32(0)
var size = UInt32(MemoryLayout<UInt32>.size)
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
let status = AudioObjectGetPropertyData(device, &addr, 0, nil, &size, &running)
return status == noErr && running != 0
}
func audioProcessObjectIDs() -> [AudioObjectID] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size = UInt32(0)
let sysID = AudioObjectID(kAudioObjectSystemObject)
guard AudioObjectGetPropertyDataSize(sysID, &addr, 0, nil, &size) == noErr, size > 0 else {
return []
}
var ids = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout<AudioObjectID>.size)
guard AudioObjectGetPropertyData(sysID, &addr, 0, nil, &size, &ids) == noErr else { return [] }
return ids
}
func processIsRunningInput(_ object: AudioObjectID) -> Bool {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioProcessPropertyIsRunningInput,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var value = UInt32(0)
var size = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value)
return status == noErr && value != 0
}
func processPID(_ object: AudioObjectID) -> Int32? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioProcessPropertyPID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var pid = pid_t(-1)
var size = UInt32(MemoryLayout<pid_t>.size)
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &pid) == noErr, pid >= 0 else {
return nil
}
return Int32(pid)
}
func processBundleID(_ object: AudioObjectID) -> String? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioProcessPropertyBundleID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var value: Unmanaged<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr,
let cf = value else { return nil }
let str = cf.takeRetainedValue() as String
return str.isEmpty ? nil : str
}
func processPath(_ pid: Int32) -> String? {
var buffer = [CChar](repeating: 0, count: 4096)
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
guard length > 0 else { return nil }
return String(cString: buffer)
}
struct MicOwner: Equatable {
let pid: Int32
let bundleId: String
let path: String
}
/// Processes currently capturing from any input device (macOS 14.4+;
/// returns [] where unsupported and the device-level fallback takes over).
func micOwners() -> [MicOwner] {
var owners: [MicOwner] = []
for object in audioProcessObjectIDs() where processIsRunningInput(object) {
guard let pid = processPID(object) else { continue }
owners.append(MicOwner(
pid: pid,
bundleId: processBundleID(object) ?? "",
path: processPath(pid) ?? ""))
}
return owners.sorted { $0.pid < $1.pid }
}
func jsonEscape(_ s: String) -> String {
var out = ""
for ch in s.unicodeScalars {
switch ch {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if ch.value < 0x20 {
out += String(format: "\\u%04x", ch.value)
} else {
out.unicodeScalars.append(ch)
}
}
}
return out
}
// Exit when the parent closes our stdin (app quit/crash) never orphan.
Thread {
while readLine(strippingNewline: false) != nil {}
exit(0)
}.start()
setbuf(stdout, nil)
var lastInUse: Bool? = nil
var lastOwners: [MicOwner] = []
while true {
let owners = micOwners()
// Re-resolve the default device every poll: the user can switch input
// devices (AirPods in/out) mid-session.
let deviceInUse = defaultInputDeviceID().map(isRunningSomewhere) ?? false
let inUse = deviceInUse || !owners.isEmpty
if inUse != lastInUse || owners != lastOwners {
lastInUse = inUse
lastOwners = owners
let ownerJson = owners.map { o in
"{\"pid\":\(o.pid),\"bundleId\":\"\(jsonEscape(o.bundleId))\",\"path\":\"\(jsonEscape(o.path))\"}"
}.joined(separator: ",")
print("{\"micInUse\":\(inUse),\"owners\":[\(ownerJson)]}")
}
Thread.sleep(forTimeInterval: 1.0)
}

View file

@ -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';

View file

@ -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<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
// 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<typeof setTimeout> | 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<typeof workspaceShared.WorkspaceChangeEvent>): 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<typeof workspaceShared.WorkspaceCh
/**
* Start workspace watcher
* Watches the configured workspace root recursively and emits change events to renderer
* Watches the user-facing workspace roots recursively and emits change events to renderer
*
* This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes
* (both from IPC handlers and external changes like terminal/git).
* The watcher runs as a main-process service and catches all filesystem changes
* under the watched roots (both from IPC handlers and external changes like
* terminal/git).
*
* Safe to call multiple times - guards against duplicate watchers.
*/
@ -622,34 +634,31 @@ export function stopWorkspaceWatcher(): void {
changeQueue.clear();
}
function emitRunEvent(event: z.infer<typeof RunEvent>): 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<typeof RunEvent>): void {
broadcastToWindows('runs:events', event);
}
function emitServiceEvent(event: z.infer<typeof ServiceEvent>): 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<CodeSession> {
@ -669,12 +678,7 @@ export async function startCodeSessionStatusWatcher(): Promise<void> {
const tracker = container.resolve<CodeSessionStatusTracker>('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<void> {
// New runtime: session bus → renderer windows (session-design.md §10).
function emitSessionEvent(event: SessionBusEvent): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('sessions:events', event);
}
}
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<Electron.WebContents, Set<string>>();
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<ITurnEventBus>('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>('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<ISessions>('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<ISessions>('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<ISessions>('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 {};
},

View file

@ -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();
}
});

View file

@ -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<string, string> = {
"Google Chrome": "Chrome",
"Microsoft Edge": "Edge",
"Brave Browser": "Brave",
"Microsoft Teams": "Teams",
};
export interface MeetingPopupPayload {
title: string;
message: string;
hasCalendarEvent: boolean;
}
let popupWin: BrowserWindow | null = null;
let currentPayload: MeetingPopupPayload | null = null;
let currentMeeting: DetectedMeeting | null = null;
let dismissTimer: NodeJS.Timeout | null = null;
let onTakeNotes: ((meeting: DetectedMeeting) => void) | null = null;
/** Main registers the take-notes handoff once at startup. */
export function initMeetingPopup(handlers: { onTakeNotes: (meeting: DetectedMeeting) => void }): void {
onTakeNotes = handlers.onTakeNotes;
}
export function getMeetingPopupPayload(): MeetingPopupPayload | null {
return currentPayload;
}
export function handleMeetingPopupAction(action: "take-notes" | "dismiss"): void {
const meeting = currentMeeting;
closeMeetingPopup();
if (action === "take-notes" && meeting) {
onTakeNotes?.(meeting);
}
}
export function closeMeetingPopup(): void {
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
currentPayload = null;
currentMeeting = null;
if (popupWin && !popupWin.isDestroyed()) popupWin.destroy();
popupWin = null;
}
export function showMeetingPopup(meeting: DetectedMeeting): void {
const eventSummary =
typeof meeting.calendarEvent?.summary === "string"
? (meeting.calendarEvent.summary as string).trim()
: "";
const appLabel = SHORT_APP_NAMES[meeting.appName] ?? meeting.appName;
const payload: MeetingPopupPayload = {
// Lean two-liner: "Meeting detected" / "Chrome" — or the event name
// over the platform when a calendar event is happening now.
title: eventSummary ? eventSummary : meeting.title,
message: appLabel,
hasCalendarEvent: Boolean(meeting.calendarEvent),
};
// Replace any popup that's still up (stale detection loses to fresh).
closeMeetingPopup();
currentPayload = payload;
currentMeeting = meeting;
// Top-left of the display the user is actually on (cursor display), not
// necessarily the primary one.
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
const workArea = display.workArea;
const popupDir = path.dirname(fileURLToPath(import.meta.url));
const preloadPath = app.isPackaged
? path.join(popupDir, "../preload/dist/preload.js")
: path.join(popupDir, "../../../preload/dist/preload.js");
const win = new BrowserWindow({
width: POPUP_WIDTH,
height: POPUP_HEIGHT,
x: workArea.x + 24,
// Sit a bit clear of the menu bar rather than hugging it.
y: workArea.y + 44,
// NSPanel (macOS): non-activating, and — unlike a regular window with
// visibleOnFullScreen — can float over fullscreen Spaces without
// turning Rowboat into an "agent" app that loses its Dock icon.
...(process.platform === "darwin" ? { type: "panel" as const } : {}),
frame: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
show: false,
backgroundColor: "#1d1d1d",
hasShadow: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: preloadPath,
},
});
// Screen-saver level + fullscreen-auxiliary: visible over fullscreen
// meeting apps on every workspace — the whole point of the popup is to be
// seen while the user is IN the meeting, wherever that is.
win.setAlwaysOnTop(true, "screen-saver");
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// visibleOnFullScreen flips the app's activation policy to "accessory":
// the Dock icon disappears and the app can no longer take foreground
// focus — clicking "Take Notes" then hands focus to whatever is next in
// the window stack instead of Rowboat. Restore the policy immediately:
// the popup keeps its fullscreen-auxiliary collection behavior (it's a
// non-activating panel, same trick as Zoom's floating controls), while
// Rowboat stays a regular app.
if (process.platform === "darwin") {
void app.dock?.show();
}
win.webContents.once("did-finish-load", () => {
if (win.isDestroyed()) return;
win.webContents.send("meetingDetect:payload", payload);
// showInactive: the user is in a meeting app right now — appearing
// must not steal focus from it.
win.showInactive();
});
win.on("closed", () => {
if (popupWin === win) popupWin = null;
});
popupWin = win;
if (app.isPackaged) {
win.loadURL("app://-/index.html#meeting-detected");
} else {
win.loadURL("http://localhost:5173/#meeting-detected");
}
dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS);
}

View file

@ -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({

View file

@ -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 14 (index 03), 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");
}

View file

@ -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",

View file

@ -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;

View file

@ -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<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
@ -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<LanguageModelUsage> | 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<LanguageModelUsage | null>(null)
const [, setModelUsage] = useState<UsageSummary | null>(null)
const [runId, setRunId] = useState<string | null>(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<string, string>())
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
// 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<string, 'low' | 'medium' | 'high'>())
// Work directory is per-chat. Keyed by tab id; null/absent means none set.
const [workDirByTab, setWorkDirByTab] = useState<Record<string, string | null>>({})
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<ReturnType<typeof buildMiddlePaneContext>>) => ({
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() {
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
{item.content && (
<MessageContent>
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{item.content}
</MessageResponse>
</MessageContent>
<div className="flex flex-col items-end">
<MessageContent>
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{item.content}
</MessageResponse>
</MessageContent>
<MessageCopyButton text={item.content} className="mt-0.5" />
</div>
)}
</Message>
)
@ -5921,26 +5989,29 @@ function App() {
const { message, files } = parseAttachedFiles(item.content)
return (
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{files.map((filePath, index) => (
<span
key={index}
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
>
@{wikiLabel(filePath)}
</span>
))}
</div>
)}
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{message}
</MessageResponse>
</MessageContent>
<div className="flex flex-col items-end">
<MessageContent>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{files.map((filePath, index) => (
<span
key={index}
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
>
@{wikiLabel(filePath)}
</span>
))}
</div>
)}
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{message}
</MessageResponse>
</MessageContent>
<MessageCopyButton text={message} className="mt-0.5" />
</div>
</Message>
)
}
@ -6039,6 +6110,24 @@ function App() {
)
}
if (isTurnUsageMessage(item)) {
return (
<div key={item.id} className="-mt-6 -ml-1 flex items-center justify-start gap-1" data-message-id={item.id}>
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
if (isErrorMessage(item)) {
if (matchBillingError(item.message)) {
return null
@ -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}

View file

@ -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;
};

View file

@ -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 (
<button
type="button"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100",
className
)}
>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
</button>
);
};
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
@ -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
)}

View file

@ -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 = ({
</div>
</div>
)}
{showDetails && externalAction && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
{externalAction.label}
</p>
<p className="text-xs font-mono font-medium text-foreground break-all">
{externalAction.detail}
</p>
</div>
)}
{showDetails && !command && !filePermission && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
@ -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")}
>
<CheckIcon className="size-4" />
Approve
</Button>
{(command || filePermission) && (
{hasScopeActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View file

@ -58,8 +58,8 @@ const CARD_CSS = `
--ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
}
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
.ma-inner { max-width:1120px; margin:0 auto; padding:34px 30px 48px; }
.ma-h1 { font-size:24px; font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); }
.ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); }
.ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }

View file

@ -466,7 +466,7 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
<div className="shrink-0 border-b border-border pr-4 py-2 flex items-center gap-3">
<Popover>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
@ -884,7 +884,7 @@ function NoteRow({
const row = (
<tr
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
className="border-b border-black/10 dark:border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onSelectNote(note.path)}
>
{visibleColumns.map((col) => (

View file

@ -15,7 +15,8 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
import type { ConversationItem } from '@/lib/chat-conversation'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { fetchAgentRunTranscript } from '@/lib/agent-transcript'
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
import { CompactConversation } from '@/components/compact-conversation'
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
import { HtmlFileViewer } from '@/components/html-file-viewer'
@ -1116,29 +1117,9 @@ function RunTranscriptView({
isInFlight: boolean
onBack: () => void
}) {
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
void (async () => {
try {
const t = await fetchAgentRunTranscript(runId)
if (cancelled) return
setTranscript(t)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
setTranscript(null)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [runId])
// Live via the turns:events spine: an in-flight run's transcript streams
// in as the agent works; settled runs render from one snapshot fetch.
const { transcript, loading, error } = useAgentRunTranscript(runId)
const summary = transcript ?? undefined
const items: ConversationItem[] = transcript?.items ?? []
@ -1733,22 +1714,20 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<ListChecks className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Background tasks</h2>
</div>
<h2 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Background tasks</h2>
<Button size="sm" onClick={() => setShowNewDialog(true)}>
New task
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
Persistent agents that fire on a schedule or in response to events. Toggle a task inactive to pause it.
</p>
</div>
<div className="flex-1 overflow-auto p-6">
<div className="flex-1 overflow-auto">
<div className="mx-auto h-full w-full max-w-[1120px] px-[30px] pb-12">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
@ -1845,16 +1824,17 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
</td>
<td className="px-4 py-3">
{isRunning ? (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2 pl-7">
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
<Loader2 className="size-3 animate-spin" />
Updating
Updating
</span>
<Button
variant="destructive"
size="sm"
onClick={() => handleStop(task.slug)}
disabled={isStopping}
className="h-auto gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
>
{isStopping ? <Loader2 className="size-3 animate-spin" /> : <Square className="size-3" />}
Stop
@ -1885,6 +1865,7 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
</table>
</div>
)}
</div>
</div>
<NewTaskDialog

View file

@ -1,7 +1,6 @@
import { useEffect, useState } from "react"
import { Coffee } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
/**
@ -30,6 +29,10 @@ export function CaffeinateIndicator() {
}
}, [])
// Render nothing while off — an invisible placeholder would leave a
// permanent 32px hole between the header controls.
if (!enabled) return null
return (
<Tooltip>
<TooltipTrigger asChild>
@ -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"
>
<Coffee className="size-4" />
</button>
</TooltipTrigger>
{enabled && (
<TooltipContent side="bottom">
Caffeinate is on your Mac won't sleep. Click to turn off.
</TooltipContent>
)}
<TooltipContent side="bottom">
Caffeinate is on your Mac won't sleep. Click to turn off.
</TooltipContent>
</Tooltip>
)
}

View file

@ -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 (
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
<Sparkles className="size-[17px]" />
</div>
<div>
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
<div className="text-xs text-muted-foreground">Ask anything, or start with a suggestion.</div>
</div>
</div>
<div className={cn('mx-auto flex w-full flex-col gap-5 py-6', wide ? 'max-w-4xl px-4' : 'max-w-md px-2')}>
<div>
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Get started
<div className={cn('font-semibold tracking-tight', wide ? 'text-2xl' : 'text-lg')}>
What are we working on?
</div>
<div className="flex flex-col gap-2">
{SUGGESTED_ACTIONS.map((action) => (
<button
key={action.title}
type="button"
onClick={() => onPickPrompt(action.prompt)}
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-[12.8px] font-medium">{action.title}</div>
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
</div>
</button>
))}
<div className={cn('mt-1 text-muted-foreground', wide ? 'text-[15px]' : 'text-[13px]')}>
Ask anything, or start with a suggestion.
</div>
</div>
<ToolConnectionsCard />
<div className="overflow-hidden rounded-xl border border-border">
{SUGGESTED_ACTIONS.map((action, i) => (
<button
key={action.title}
type="button"
onClick={() => onPickPrompt(action.prompt)}
className={cn(
'group flex w-full items-center gap-1.5 text-left transition-colors hover:bg-accent/50',
wide ? 'px-3.5 py-3' : 'px-3 py-2.5',
i > 0 && 'border-t border-border/60',
)}
>
<action.icon className={cn('mr-2 shrink-0 text-foreground/80', wide ? 'size-4' : 'size-3.5')} strokeWidth={1.75} />
<span className={cn('shrink-0 font-medium text-foreground', wide ? 'text-sm' : 'text-[13px]')}>
{action.title}
</span>
<span className={cn('truncate text-muted-foreground', wide ? 'text-[13px]' : 'text-[12px]')}>
{action.sub}
</span>
<ArrowRight className={cn('ml-auto shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground', wide ? 'size-3.5' : 'size-3')} />
</button>
))}
</div>
<ToolConnectionsCard compact={!wide} />
</div>
)
}

View file

@ -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({
<span className="truncate">{activeTitle}</span>
</div>
)}
{showUsage && (
<TokenUsageMenu
usage={sessionUsage}
scope="session"
className="titlebar-no-drag my-1 shrink-0"
align="end"
/>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -109,6 +150,34 @@ export function ChatHeader({
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Chat options"
>
<MoreHorizontal className="size-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunId}
onSelect={() => {
void handleDownloadChatLog()
}}
>
<Bug className="size-4" />
Download chat log
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View file

@ -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<string>
onSelectRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onRenameRun?: (runId: string, title: string) => void
onDeleteRun: (runId: string) => Promise<void> | void
onNewChat?: () => void
onOpenSearch?: () => void
@ -44,11 +53,14 @@ export function ChatHistoryView({
processingRunIds,
onSelectRun,
onOpenInNewTab,
onRenameRun,
onDeleteRun,
onNewChat,
onOpenSearch,
}: ChatHistoryViewProps) {
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
@ -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 (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Chat history</h1>
<div className="flex items-center gap-2">
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
)}
{onNewChat && (
<Button size="sm" onClick={onNewChat}>
<SquarePen className="size-4" />
New chat
</Button>
)}
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
<div className="flex items-center justify-between gap-4">
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Chat history</h1>
<div className="flex items-center gap-2">
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
)}
{onNewChat && (
<Button size="sm" onClick={onNewChat}>
<SquarePen className="size-4" />
New chat
</Button>
)}
</div>
</div>
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
{sortedRuns.length === 0
? 'Every conversation you have shows up here.'
: `${sortedRuns.length} ${sortedRuns.length === 1 ? 'conversation' : 'conversations'}, newest first.`}
</p>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0 text-right">Last modified</div>
</div>
<div className="mx-auto w-full max-w-[1120px] px-[30px] pb-12">
{sortedRuns.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No chats yet.</div>
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground">
No chats yet.
</div>
) : (
sortedRuns.map((run) => {
const isActive = currentRunId === run.id
const isProcessing = processingRunIds?.has(run.id)
return (
<ContextMenu key={run.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={(e) => {
if (e.metaKey && onOpenInNewTab) {
onOpenInNewTab(run.id)
} else {
onSelectRun(run.id)
}
}}
className={[
'flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent',
isActive ? 'bg-accent/60' : '',
].join(' ')}
>
<div className="flex flex-1 items-center gap-2 min-w-0">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.modifiedAt)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isProcessing && (
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="flex items-center border-b border-border/60 bg-muted/30 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div className="min-w-0 flex-1">Title</div>
<div className="w-28 shrink-0 text-right">Last modified</div>
<div className="w-7 shrink-0" />
</div>
{sortedRuns.map((run) => {
const isActive = currentRunId === run.id
const isProcessing = processingRunIds?.has(run.id)
return (
<ContextMenu key={run.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group relative border-b border-border/50 transition-colors last:border-b-0 hover:bg-muted/20',
isActive && 'bg-muted/30',
)}
>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
})
{renamingId === run.id ? (
<div className="flex items-center px-4 py-1.5">
<input
autoFocus
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitRename(run.id)
} else if (e.key === 'Escape') {
e.preventDefault()
setRenamingId(null)
}
}}
onBlur={() => commitRename(run.id)}
className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring"
/>
</div>
) : (
<>
<button
type="button"
onClick={(e) => {
if (e.metaKey && onOpenInNewTab) {
onOpenInNewTab(run.id)
} else {
onSelectRun(run.id)
}
}}
className="flex w-full items-center px-4 py-2.5 text-left text-sm"
>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{run.title || '(Untitled chat)'}
</span>
<span className="w-28 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.modifiedAt)}
</span>
<span className="w-7 shrink-0" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Chat options"
onClick={(e) => e.stopPropagation()}
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{onOpenInNewTab && (
<>
<DropdownMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{onRenameRun && (
<DropdownMenuItem onClick={() => startRename(run)}>
<Pencil className="mr-2 size-4" />
Rename
</DropdownMenuItem>
)}
{!isProcessing && (
<DropdownMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{onRenameRun && (
<ContextMenuItem onClick={() => startRename(run)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
)}
{!isProcessing && (
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
</div>
</div>

View file

@ -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<string>(['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<string>(['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<ModelPickerGroup, { kind: 'live' }>
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 (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${group.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{status === 'loading' && (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<LoaderIcon className="h-3 w-3 animate-spin" />
Loading models
</div>
)}
{status === 'error' && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refetch()
}}
className="text-xs"
>
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</>
)
}
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<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
const [modelGroups, setModelGroups] = useState<ModelPickerGroup[]>([])
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<ConfiguredModel | null>(null)
const loadModelConfigEpoch = useRef(0)
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(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<Record<string, boolean>>({})
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<string>()
const push = (provider: string, model: string) => {
if (!model) return
const key = `${provider}/${model}`
if (seen.has(key)) return
seen.add(key)
models.push({ provider: provider as ProviderName, model })
}
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<string, string[]> = {}
const reasoningFlags: Record<string, boolean> = {}
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<string, unknown>
const hasKey = typeof e.apiKey === 'string' && (e.apiKey as string).trim().length > 0
const hasBaseURL = typeof e.baseURL === 'string' && (e.baseURL as string).trim().length > 0
if (!hasKey && !hasBaseURL) continue // provider not configured
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<ConfiguredModel[]>(() => {
if (!defaultModel) return configuredModels
const defaultKey = `${defaultModel.provider}/${defaultModel.model}`
if (configuredModels.some((m) => `${m.provider}/${m.model}` === defaultKey)) return configuredModels
return [defaultModel, ...configuredModels]
}, [configuredModels, defaultModel])
// 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<HTMLInputElement>(null)
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
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<ConfiguredModel | null>(() => {
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({
</DropdownMenu>
)}
<div className="flex-1" />
{reasoningAvailable && (
<DropdownMenu>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Brain className="h-3 w-3 shrink-0" />
{reasoningEffort !== '' && (
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === reasoningEffort)?.label}</span>
)}
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Reasoning effort applies to your next message</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={reasoningEffort} onValueChange={handleReasoningEffortChange}>
{REASONING_EFFORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{lockedModel ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
@ -1295,8 +1504,19 @@ function ChatInputInner({
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} fixed for this chat
</TooltipContent>
</Tooltip>
) : pickerModels.length > 0 ? (
<DropdownMenu>
) : (
<DropdownMenu
onOpenChange={(open) => {
// 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)
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -1304,33 +1524,103 @@ function ChatInputInner({
>
<span className="min-w-0 truncate">
{getSelectedModelDisplayName(
pickerModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model
(activeModelKey ? activeModelKey.slice(activeModelKey.indexOf('/') + 1) : '')
|| defaultModel?.model
|| pickerModels[0]?.model
|| 'Model'
)}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
onValueChange={handleModelChange}
>
{pickerModels.map((m) => {
const key = `${m.provider}/${m.model}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m.model}</span>
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
{modelGroups.length === 0 && !standaloneDefault ? (
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
) : (
<>
{/* 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. */}
<div className="bg-popover p-1">
<input
ref={modelFilterInputRef}
value={modelFilter}
onChange={(e) => 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"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1 pt-0">
<DropdownMenuRadioGroup
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
onValueChange={handleModelChange}
>
{standaloneDefault && standaloneVisible && (
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
<span className="truncate">{standaloneDefault.model}</span>
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
</span>
</DropdownMenuRadioItem>
)}
{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 (
<LiveProviderGroupItems
key={g.flavor}
group={g}
label={label}
pinnedModels={pinned}
filter={modelFilterValue}
onModelRowsChange={handleLiveGroupRows}
/>
)
}
const visibleModels = modelFilterValue
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
: g.models
if (visibleModels.length === 0) return null
return (
<Fragment key={g.flavor}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
</Fragment>
)
})}
{modelFilterValue && !anyModelRowVisible && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)}
</DropdownMenuRadioGroup>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
)}
{onStartCall && (
<div className="flex shrink-0 items-center">
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
@ -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}

View file

@ -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<string, ChatTabViewState>
viewportAnchors?: Record<string, ChatViewportAnchorState>
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<string, string | null>
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
@ -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({
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
{item.content && (
<MessageContent>
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{item.content}
</MessageResponse>
</MessageContent>
<div className="flex flex-col items-end">
<MessageContent>
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{item.content}
</MessageResponse>
</MessageContent>
<MessageCopyButton text={item.content} className="mt-0.5" />
</div>
)}
</Message>
)
@ -424,26 +404,29 @@ export function ChatSidebar({
const { message, files } = parseAttachedFiles(item.content)
return (
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{files.map((filePath, index) => (
<span
key={index}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
@{wikiLabel(filePath)}
</span>
))}
</div>
)}
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{message}
</MessageResponse>
</MessageContent>
<div className="flex flex-col items-end">
<MessageContent>
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{files.map((filePath, index) => (
<span
key={index}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
@{wikiLabel(filePath)}
</span>
))}
</div>
)}
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{message}
</MessageResponse>
</MessageContent>
<MessageCopyButton text={message} className="mt-0.5" />
</div>
</Message>
)
}
@ -508,6 +491,24 @@ export function ChatSidebar({
)
}
if (isTurnUsageMessage(item)) {
return (
<div key={item.id} className="-mt-6 -ml-1 flex items-center justify-start gap-1" data-message-id={item.id}>
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
if (isErrorMessage(item)) {
if (matchBillingError(item.message)) {
return null
@ -600,38 +601,11 @@ export function ChatSidebar({
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
sessionUsage={activeTabState.sessionUsage}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Chat options"
>
<MoreHorizontal className="size-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunId}
onSelect={() => {
void handleDownloadChatLog()
}}
>
<Bug className="size-4" />
Download chat log
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
@ -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}

View file

@ -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[] }) {
</div>
)
}
if (isTurnUsageMessage(item)) {
return (
<div key={item.id} className="-ml-1 flex items-center justify-start gap-1">
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
className="size-5 border-transparent bg-transparent hover:bg-transparent"
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
if (isToolCall(item)) return <CompactToolRow key={item.id} tool={item} />
if (isChatMessage(item)) {
const isUser = item.role === 'user'

View file

@ -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" <jo@x.com>, 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
<body>, 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; }
</style>
</head><body>${html}</body></html>`
}
@ -436,24 +446,25 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSavedHeightRef = useRef<number>(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<void>
/** The label registry (built-ins + user-defined) for the chip and correction dropdown. */
labels?: EmailLabelInfo[]
}) {
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
const [selfEmail, setSelfEmail] = useState<string>('')
@ -2059,6 +2085,30 @@ function ThreadDetail({
<div className={cn('gmail-detail gmail-detail-inline', hidden && 'gmail-detail-hidden')}>
<div className="gmail-detail-toolbar">
<div className="gmail-thread-subject-inline">{thread.subject || '(No subject)'}</div>
{onSetCategory && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="gmail-row-chip gmail-category-chip"
title="Correct the category — the classifier learns from this"
>
{thread.category ? labelNameFor(labels, thread.category) : 'Categorize'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="font-sans">
{labels.map((l) => (
<DropdownMenuItem
key={l.id}
onSelect={() => { void onSetCategory(thread.threadId, l.id) }}
className={cn(l.id === thread.category && 'font-semibold')}
>
{l.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<button type="button" className="gmail-icon-button" onClick={onClose} aria-label="Close thread">
<span>×</span>
</button>
@ -2150,6 +2200,10 @@ const ThreadRow = memo(function ThreadRow({
isLeaving,
keysDisabled,
section,
chip,
chipWaiting,
onSetCategory,
labels,
onToggle,
onMarkRead,
onArchive,
@ -2168,6 +2222,12 @@ const ThreadRow = memo(function ThreadRow({
keysDisabled: boolean
/** Which inbox section the row is rendered in; null hides the importance toggle (e.g. search results). */
section: 'important' | 'other' | null
/** Status pill after the subject — "Reply ready" or waiting age ("Waiting 3d"). The category chip renders automatically from thread.category. */
chip?: string | null
chipWaiting?: boolean
onSetCategory?: (threadId: string, category: EmailCategory) => Promise<void>
/** Label registry — stable reference from EmailView state (this row is memoized). */
labels: EmailLabelInfo[]
onToggle: (thread: GmailThread) => void
onMarkRead: (threadId: string, read?: boolean) => Promise<void>
onArchive: (threadId: string) => Promise<void>
@ -2180,6 +2240,14 @@ const ThreadRow = memo(function ThreadRow({
}) {
const latest = latestMessage(thread)
const isUnread = thread.unread === true
// Category chip on classified rows so the list itself answers "worth
// opening?". Plain correspondence gets none — with granular labels
// (Investor, Customer, …) available, "Direct" is the default case and
// absence reads cleaner than a chip stating it. It stays available in the
// correction dropdown and the Everything-else filter pills.
const categoryChip = thread.category && thread.category !== 'correspondence'
? labelNameFor(labels, thread.category)
: null
const stop = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation()
}
@ -2201,6 +2269,8 @@ const ThreadRow = memo(function ThreadRow({
<span className="gmail-row-content">
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
{categoryChip && <span className="gmail-row-chip">{categoryChip}</span>}
{chip && <span className={cn('gmail-row-chip', chipWaiting ? 'gmail-row-chip-waiting' : 'gmail-row-chip-ready')}>{chip}</span>}
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
@ -2254,12 +2324,88 @@ const ThreadRow = memo(function ThreadRow({
hidden={!isSelected}
keysDisabled={keysDisabled}
onComposingChange={onComposingChange}
onSetCategory={onSetCategory}
labels={labels}
/>
)}
</div>
)
})
// Free-text standing instructions for the email agent. Saved to
// config/email_instructions.md and injected into every classification /
// draft call as the highest-priority section of the system prompt.
function EmailInstructionsDialog({ open, onOpenChange, onSaved }: { open: boolean; onOpenChange: (open: boolean) => void; onSaved?: () => void }) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
window.ipc.invoke('gmail:getEmailInstructions', {})
.then((res) => { if (!cancelled) setText(res.instructions) })
.catch(() => {})
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [open])
const save = async () => {
setSaving(true)
try {
const result = await window.ipc.invoke('gmail:setEmailInstructions', { instructions: text })
if (result.ok) {
toast('Instructions saved — they apply to every email from now on.', 'success')
onOpenChange(false)
onSaved?.()
} else {
toast(`Could not save: ${result.error ?? 'unknown error'}`, 'error')
}
} catch (err) {
toast(`Could not save: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="font-sans sm:max-w-[560px]">
<DialogTitle className="text-base font-semibold">Email agent instructions</DialogTitle>
<p className="text-sm text-muted-foreground">
Standing instructions for how your email is labeled, prioritized, and how draft replies are written.
The agent follows these over its defaults, on every new email. You can also define your own labels
here e.g. Create a label Clients for emails from active client accounts.
</p>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
disabled={loading}
rows={10}
maxLength={8000}
placeholder={
'Examples:\n' +
'- Emails from my investors are always important.\n' +
'- Investor updates I receive are direct emails, not news.\n' +
'- Never draft replies to recruiters or cold outreach.\n' +
'- Keep drafts short and casual; sign off with my first name.\n' +
'- Payment failure alerts are important; other billing emails are receipts.\n' +
'- Create a label "Customers" for emails from paying customers.'
}
className="min-h-[220px] font-sans text-sm leading-relaxed"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={() => void save()} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
function ShortcutKey({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-foreground">
@ -2420,6 +2566,13 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
const closeCompose = useCallback(() => setComposeOpen(false), [])
// Inbox vs Drafts. Drafts are fetched live (they're not in the inbox cache).
const [view, setView] = useState<'inbox' | 'drafts'>('inbox')
// Category filter for "Everything else" (null = all) + whole-section counts
// from the last backend response, which drive the filter pills.
const [otherCategory, setOtherCategory] = useState<string | null>(null)
const otherCategoryRef = useRef<string | null>(null)
otherCategoryRef.current = otherCategory
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({})
const [bulkArchiving, setBulkArchiving] = useState(false)
const [drafts, setDrafts] = useState<GmailThread[]>(() => persistedDrafts ?? [])
const [draftsLoading, setDraftsLoading] = useState(false)
const [draftsError, setDraftsError] = useState<string | null>(null)
@ -2432,6 +2585,17 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
// Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [instructionsOpen, setInstructionsOpen] = useState(false)
// Label registry (built-ins + user-defined). Fetched on mount and again
// after the instructions dialog saves, since saving can create labels.
const [emailLabels, setEmailLabels] = useState<EmailLabelInfo[]>(BUILTIN_LABELS)
const refreshLabels = useCallback(async () => {
try {
const res = await window.ipc.invoke('gmail:getEmailLabels', {})
if (res.labels?.length) setEmailLabels(res.labels)
} catch { /* keep built-ins */ }
}, [])
useEffect(() => { void refreshLabels() }, [refreshLabels])
// Keyboard navigation: the j/k focus cursor over the visible rows, plus the
// "?" shortcuts overlay. lastFocusedIndexRef remembers the cursor's position
// so it can re-anchor when the focused row disappears (archive/trash/reload).
@ -2649,6 +2813,33 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
}
}, [important.threads, other.threads, markLeaving, setSection])
// User corrects a thread's category: sticky on the thread + recorded as a
// correction the classifier learns from (few-shot).
const setCategoryAction = useCallback(async (threadId: string, category: EmailCategory) => {
try {
const result = await window.ipc.invoke('gmail:setCategory', { threadId, category })
if (result.ok) {
updateThreadInState(threadId, (t) => ({ ...t, category }))
setCategoryCounts((prev) => {
const next = { ...prev }
// Only 'other'-section threads are counted; adjust optimistically and
// let the next backend response correct any drift.
const prevCat = [...important.threads, ...other.threads].find((t) => t.threadId === threadId)?.category ?? 'unclassified'
if (other.threads.some((t) => t.threadId === threadId)) {
next[prevCat] = Math.max(0, (next[prevCat] ?? 1) - 1)
next[category] = (next[category] ?? 0) + 1
}
return next
})
toast(`Filed as ${labelNameFor(emailLabels, category)} — noted for similar emails.`, 'success')
} else if (result.error) {
toast(`Could not update category: ${result.error}`, 'error')
}
} catch (err) {
toast(`Could not update category: ${err instanceof Error ? err.message : String(err)}`, 'error')
}
}, [updateThreadInState, important.threads, other.threads, emailLabels])
const trashThreadAction = useCallback(async (threadId: string) => {
markLeaving(threadId, true)
try {
@ -2714,8 +2905,21 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
// reload to be discarded whenever Other was reloaded in the same tick.)
const epochsRef = useRef<Record<InboxSection, number>>({ important: 0, other: 0 })
const sectionChannel = (section: InboxSection) =>
section === 'important' ? 'gmail:getImportant' as const : 'gmail:getEverythingElse' as const
const fetchSectionPage = useCallback(async (section: InboxSection, cursor?: string) => {
const result = section === 'important'
? await window.ipc.invoke('gmail:getImportant', { cursor, limit: PAGE_SIZE })
: await window.ipc.invoke('gmail:getEverythingElse', {
cursor,
limit: PAGE_SIZE,
category: otherCategoryRef.current ?? undefined,
})
// Counts describe the whole 'other' section regardless of filter/page —
// keep the pills fresh on every response that carries them.
if (section === 'other' && result.categoryCounts) {
setCategoryCounts(result.categoryCounts)
}
return result
}, [])
const loadNextPage = useCallback(async (section: InboxSection) => {
const current = section === 'important' ? important : other
@ -2724,10 +2928,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
const epoch = epochsRef.current[section]
setSection(section, (prev) => ({ ...prev, loadingPage: true }))
try {
const result = await window.ipc.invoke(sectionChannel(section), {
cursor: current.nextCursor ?? undefined,
limit: PAGE_SIZE,
})
const result = await fetchSectionPage(section, current.nextCursor ?? undefined)
if (epoch !== epochsRef.current[section]) return
setSection(section, (prev) => ({
threads: [...prev.threads, ...result.threads],
@ -2740,7 +2941,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
console.warn(`[Gmail] page load failed for ${section}:`, err)
setSection(section, (prev) => ({ ...prev, loadingPage: false }))
}
}, [important, other, setSection])
}, [important, other, setSection, fetchSectionPage])
const reloadFirstPage = useCallback(async (section: InboxSection, options: { silent?: boolean } = {}) => {
const epoch = ++epochsRef.current[section]
@ -2750,9 +2951,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
setSection(section, () => ({ ...initialSectionState, loadingPage: true }))
}
try {
const result = await window.ipc.invoke(sectionChannel(section), {
limit: PAGE_SIZE,
})
const result = await fetchSectionPage(section)
if (epoch !== epochsRef.current[section]) return
setSection(section, () => ({
threads: result.threads,
@ -2765,33 +2964,59 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
console.warn(`[Gmail] initial page load failed for ${section}:`, err)
setSection(section, (prev) => ({ ...prev, loadingPage: false }))
}
}, [setSection])
}, [setSection, fetchSectionPage])
// Initial load — fetch page 1 of Important. On first-ever mount we do a
// non-silent load (shows loading state). On re-mount with persisted state we
// do a silent reconcile against the cache — necessary because the watcher
// subscription only runs while mounted, so any cache changes that happened
// while the panel was unmounted would otherwise stay invisible.
// Initial load — fetch page 1 of BOTH sections. Everything else used to
// load lazily once Important was exhausted, but that chained on
// hasReachedEnd, which every silent live reload resets (back to page 1) —
// under sustained sync writes the chain never fired and the section never
// appeared at all. Both lists are cheap local reads (mtime-cached file
// scan), so eager is fine. On first-ever mount we do a non-silent load
// (shows loading state); on re-mount with persisted state we do a silent
// reconcile against the cache — necessary because the watcher subscription
// only runs while mounted, so any cache changes that happened while the
// panel was unmounted would otherwise stay invisible.
useEffect(() => {
if (hadPersistedDataOnMount.current) {
void reloadFirstPage('important', { silent: true })
// Reconcile Other too if it had been loaded before the unmount.
if (other.threads.length > 0) {
void reloadFirstPage('other', { silent: true })
}
} else {
void reloadFirstPage('important')
}
const silent = hadPersistedDataOnMount.current
void reloadFirstPage('important', { silent })
void reloadFirstPage('other', { silent })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Once Important is exhausted, kick off page 1 of Everything else.
// Changing the category filter refetches Everything else from page 1
// (fetchSectionPage reads the filter from otherCategoryRef). Skipped on
// mount — the initial-load effect above covers it.
const otherCategoryMounted = useRef(false)
useEffect(() => {
if (!important.hasReachedEnd) return
if (other.threads.length > 0) return
if (other.loadingPage) return
if (!otherCategoryMounted.current) {
otherCategoryMounted.current = true
return
}
void reloadFirstPage('other')
}, [important.hasReachedEnd, other.threads.length, other.loadingPage, reloadFirstPage])
}, [otherCategory, reloadFirstPage])
// Bulk gesture behind the filter pills: archive the whole category at once.
const archiveCategoryAction = useCallback(async (category: string) => {
setBulkArchiving(true)
try {
const result = await window.ipc.invoke('gmail:archiveCategory', { category })
if (result.error) {
toast(`Bulk archive failed: ${result.error}`, 'error')
} else {
toast(
`Archived ${result.archived} thread${result.archived === 1 ? '' : 's'}${result.failed ? ` (${result.failed} failed)` : ''}. They stay searchable in Gmail.`,
result.failed ? 'error' : 'success',
)
}
setOtherCategory(null) // triggers the Everything else reload above
void reloadFirstPage('important', { silent: true })
} catch (err) {
toast(`Bulk archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setBulkArchiving(false)
}
}, [reloadFirstPage])
// Live updates: watcher on inbox_lists/ → silently refresh visible sections
// when files change. Throttled to at most one reload per ~3s so a burst of
@ -2811,8 +3036,6 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
composeOpenRef.current = composeOpen
const isRefreshingRef = useRef(false)
isRefreshingRef.current = refreshing
const otherHasThreadsRef = useRef(false)
otherHasThreadsRef.current = other.threads.length > 0
const RELOAD_THROTTLE_MS = 3000
@ -2824,11 +3047,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
}
lastReloadAtRef.current = Date.now()
void reloadFirstPage('important', { silent: true })
// Only refresh Other if it had been loaded — otherwise the chained
// effect handles it once Important hits hasReachedEnd.
if (otherHasThreadsRef.current) {
void reloadFirstPage('other', { silent: true })
}
void reloadFirstPage('other', { silent: true })
}, [reloadFirstPage])
// Leading-edge throttle:
@ -2881,9 +3100,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
pendingReloadRef.current = false
lastReloadAtRef.current = Date.now()
void reloadFirstPage('important', { silent: true })
if (otherHasThreadsRef.current) {
void reloadFirstPage('other', { silent: true })
}
void reloadFirstPage('other', { silent: true })
}, [selectedThreadId, composeOpen, reloadFirstPage])
// Manual refresh: wake the background sync loop. It updates inbox_lists/,
@ -2936,13 +3153,26 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads])
const visibleDrafts = useMemo(() => filterThreads(drafts), [drafts, filterThreads])
// Split "Important" into what still needs the user vs. what they've already
// answered and are waiting on. Derived per render from the messages — thread
// state, unlike a label, can't be allowed to go stale.
const selfEmail = emailConnection?.email ?? null
const visibleNeedsYou = useMemo(
() => visibleImportant.filter((t) => !isAwaitingThem(t, selfEmail)),
[visibleImportant, selfEmail],
)
const visibleWaiting = useMemo(
() => visibleImportant.filter((t) => isAwaitingThem(t, selfEmail)),
[visibleImportant, selfEmail],
)
// ── Keyboard shortcuts (Superhuman-style) ───────────────────────────────────
// EmailView only mounts while the email tab is open, so these are naturally
// scoped to that view. Single-letter keys stay inert while typing in any
// field, while a dialog is up, or while the inline reply composer is open.
const listMode: 'search' | 'drafts' | 'inbox' = query.trim() ? 'search' : view === 'drafts' ? 'drafts' : 'inbox'
const anyModalOpen = composeOpen || settingsOpen || Boolean(editingDraft) || helpOpen
const anyModalOpen = composeOpen || settingsOpen || Boolean(editingDraft) || helpOpen || instructionsOpen
// Row identity for the focus cursor — must match each row's data-thread-id.
const rowIdOf = useCallback((thread: GmailThread) => (
@ -2956,9 +3186,9 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
const visibleList = useMemo<GmailThread[]>(() => {
if (query.trim()) return searchResults
if (view === 'drafts') return visibleDrafts
if (important.hasReachedEnd && other.threads.length > 0) return [...visibleImportant, ...visibleOther]
return visibleImportant
}, [query, searchResults, view, visibleDrafts, visibleImportant, visibleOther, important.hasReachedEnd, other.threads.length])
if (other.threads.length > 0) return [...visibleNeedsYou, ...visibleWaiting, ...visibleOther]
return [...visibleNeedsYou, ...visibleWaiting]
}, [query, searchResults, view, visibleDrafts, visibleNeedsYou, visibleWaiting, visibleOther, other.threads.length])
// Keep the cursor valid as the list changes: switching between inbox,
// search, and drafts resets it; if the focused row vanished (archived,
@ -3195,7 +3425,12 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
const closeThread = useCallback(() => setSelectedThreadId(null), [])
const renderRow = (thread: GmailThread, section: 'important' | 'other' | null = null) => {
const renderRow = (
thread: GmailThread,
section: 'important' | 'other' | null = null,
chip: string | null = null,
chipWaiting: boolean = false,
) => {
const isMounted = openedThreadIds.includes(thread.threadId)
return (
<ThreadRow
@ -3207,6 +3442,10 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
isLeaving={leavingThreadIds.has(thread.threadId)}
keysDisabled={isMounted && anyModalOpen}
section={section}
chip={chip}
chipWaiting={chipWaiting}
onSetCategory={section ? setCategoryAction : undefined}
labels={emailLabels}
onToggle={toggleThread}
onMarkRead={markThreadReadAction}
onArchive={archiveThreadAction}
@ -3302,6 +3541,15 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
onClick={() => setView('drafts')}
>Drafts{drafts.length > 0 ? ` (${drafts.length})` : ''}</button>
</div>
<button
type="button"
className="gmail-icon-button"
onClick={() => setInstructionsOpen(true)}
aria-label="Email agent instructions"
title="Email agent instructions"
>
<SlidersHorizontal size={18} />
</button>
<button
type="button"
className="gmail-icon-button"
@ -3356,25 +3604,44 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
<div className="gmail-empty-state">Could not load mail: {error}</div>
) : hasAny ? (
<div className="gmail-list" aria-label="Recent emails">
{important.threads.length > 0 && (
{visibleNeedsYou.length > 0 ? (
<section className="gmail-section">
<div className="gmail-list-header">
<span>Important</span>
<span>Needs you</span>
<span>
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
{visibleNeedsYou.length}{important.hasReachedEnd ? '' : '+'} thread{visibleNeedsYou.length === 1 ? '' : 's'}
</span>
</div>
{visibleImportant.map((t) => renderRow(t, 'important'))}
{!important.hasReachedEnd && (
<SectionSentinel
disabled={important.loadingPage || important.hasReachedEnd}
onIntersect={() => loadNextPage('important')}
loading={important.loadingPage}
/>
)}
{visibleNeedsYou.map((t) => renderRow(t, 'important', t.draft_response ? 'Reply ready' : null))}
</section>
) : important.hasReachedEnd && !important.loadingPage ? (
<div className="gmail-caughtup">Youre caught up nothing needs a reply.</div>
) : null}
{visibleWaiting.length > 0 && (
<section className="gmail-section">
<div className="gmail-list-header">
<span>Waiting on them</span>
<span>
{visibleWaiting.length}{important.hasReachedEnd ? '' : '+'} thread{visibleWaiting.length === 1 ? '' : 's'}
</span>
</div>
{visibleWaiting.map((t) => renderRow(t, 'important', waitingChip(t), true))}
</section>
)}
{important.hasReachedEnd && other.threads.length > 0 && (
{/* Pages of "Important" feed both sections above, so the sentinel
lives after them rather than inside either one. */}
{important.threads.length > 0 && !important.hasReachedEnd && (
<SectionSentinel
disabled={important.loadingPage || important.hasReachedEnd}
onIntersect={() => loadNextPage('important')}
loading={important.loadingPage}
/>
)}
{/* Loading stays lazy (chained on Important exhausting), but once
loaded the section never unmounts: silent live reloads reset
Important to page 1 (hasReachedEnd false), and gating the
render on it made this whole section vanish on every sync. */}
{(other.threads.length > 0 || otherCategory !== null) && (
<section className="gmail-section">
<div className="gmail-list-header">
<span>Everything else</span>
@ -3382,6 +3649,41 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
</span>
</div>
{Object.keys(categoryCounts).length > 0 && (
<div className="gmail-category-pills">
{[
...BUILTIN_PILL_ORDER,
...emailLabels.filter((l) => l.kind === 'custom').map((l) => l.id),
// Stale custom ids still present in counts render too.
...Object.keys(categoryCounts).filter((c) => c !== 'unclassified' && !BUILTIN_PILL_ORDER.includes(c) && !emailLabels.some((l) => l.id === c)),
'unclassified',
].filter((c) => (categoryCounts[c] ?? 0) > 0).map((cat) => (
<button
key={cat}
type="button"
className={cn('gmail-category-pill', otherCategory === cat && 'gmail-category-pill-active')}
onClick={() => setOtherCategory((prev) => (prev === cat ? null : cat))}
>
{labelNameFor(emailLabels, cat)} <span className="gmail-category-pill-count">{categoryCounts[cat]}</span>
</button>
))}
{otherCategory && otherCategory !== 'unclassified' && (
<button
type="button"
className="gmail-category-pill gmail-category-pill-archive"
disabled={bulkArchiving}
onClick={() => void archiveCategoryAction(otherCategory)}
>
{bulkArchiving
? 'Archiving…'
: `Archive all ${categoryCounts[otherCategory] ?? ''} ${labelNameFor(emailLabels, otherCategory).toLowerCase()}`}
</button>
)}
</div>
)}
{other.threads.length === 0 && otherCategory !== null && !other.loadingPage && (
<div className="gmail-caughtup">Nothing filed as {labelNameFor(emailLabels, otherCategory).toLowerCase()}.</div>
)}
{visibleOther.map((t) => renderRow(t, 'other'))}
{!other.hasReachedEnd && (
<SectionSentinel
@ -3427,6 +3729,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
)}
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
<ShortcutsHelpDialog open={helpOpen} onOpenChange={setHelpOpen} />
<EmailInstructionsDialog open={instructionsOpen} onOpenChange={setInstructionsOpen} onSaved={() => void refreshLabels()} />
</div>
)
}

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { ToolConnectionsCard } from '@/components/tool-connections-card'
import { SlackIcon } from '@/components/onboarding/provider-icons'
interface TreeNode {
path: string
@ -331,15 +332,12 @@ export function HomeView({
{/* Up-next hero */}
{nextEvent && (
<div className="flex items-center gap-[18px] rounded-xl bg-foreground p-[18px] text-background">
<div className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-background/10">
<Mic className="size-[22px]" />
</div>
<div className="flex items-center gap-[18px] rounded-xl bg-foreground px-5 py-[18px] text-background">
<div className="min-w-0 flex-1">
<div className="mb-1 text-[11px] uppercase tracking-wide text-background/55">
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-background/50">
Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)}
</div>
<div className="mb-0.5 truncate text-[17px] font-medium">{nextEvent.summary}</div>
<div className="mb-0.5 truncate text-[17px] font-semibold">{nextEvent.summary}</div>
<div className="truncate text-[13px] text-background/70">
{nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` ${timeOfDay(nextEvent.end)}` : ''}`}
{nextEvent.location ? ` · ${nextEvent.location}` : ''}
@ -432,7 +430,7 @@ export function HomeView({
{slackEnabled && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<MessageSquare className="size-[15px]" />
<SlackIcon className="size-[15px]" />
<span className="text-sm font-medium">Slack</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">Latest messages</span>
@ -548,9 +546,6 @@ export function HomeView({
onClick={onOpenChat}
className="flex items-center gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground">
<MessageSquare className="size-[15px]" />
</div>
<div className="min-w-0 flex-1 text-[13.5px] leading-snug">
<span className="font-medium">Ask anything</span>
<span className="text-muted-foreground"> create presentations, do research, collaborate on docs.</span>

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
ArrowLeft,
ChevronRight,
Copy,
ExternalLink,
@ -70,20 +69,6 @@ const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace'])
// Theme-aware accent palette for folder avatars — colored letter on a faint
// tint of the same hue. Mirrors the design's six-colour rotation.
const AVATAR_PALETTE = [
'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400',
'bg-violet-500/10 text-violet-600 dark:text-violet-400',
'bg-amber-500/10 text-amber-600 dark:text-amber-400',
'bg-rose-500/10 text-rose-600 dark:text-rose-400',
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
'bg-sky-500/10 text-sky-600 dark:text-sky-400',
] as const
function avatarClass(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]
}
function isMarkdown(node: TreeNode): boolean {
return node.kind === 'file' && node.name.toLowerCase().endsWith('.md')
@ -203,11 +188,11 @@ export function KnowledgeView({
const currentFolder = folderPath ? findNode(tree, folderPath) : null
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
<div className="mx-auto w-full max-w-[1120px] shrink-0 flex items-start justify-between gap-4 px-[30px] pt-[34px] pb-5">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Brain</h1>
<p className="mt-1 text-sm text-muted-foreground">
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Brain</h1>
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</p>
@ -242,12 +227,12 @@ export function KnowledgeView({
{graphContent}
</div>
) : mode === 'basis' ? (
<div className="flex-1 min-h-0 overflow-hidden">
<div className="mx-auto flex w-full max-w-[1120px] flex-1 min-h-0 flex-col overflow-hidden px-[30px] pb-6">
{basisContent}
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-6">
<div className="mx-auto w-full max-w-[1120px] px-[30px] py-6">
{currentFolder ? (
<FolderDetail
folder={currentFolder}
@ -261,11 +246,11 @@ export function KnowledgeView({
/>
) : (
<>
<SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" />
<SectionHeader label={`Folders · ${folders.length}`} />
{folders.length === 0 ? (
<EmptyState text="No folders yet." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
<div className="overflow-hidden rounded-xl border border-black/15 dark:border-border">
{folders.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<FolderCard
@ -285,7 +270,7 @@ export function KnowledgeView({
{looseNotes.length > 0 && (
<div className="mt-8">
<SectionHeader label={`Loose notes · ${looseNotes.length}`} />
<div className="overflow-hidden rounded-xl border border-border">
<div className="overflow-hidden rounded-xl border border-black/15 dark:border-border">
{looseNotes.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<ItemRow
@ -398,9 +383,9 @@ function QuickAction({
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Icon className="size-4 text-muted-foreground" />
<Icon className="size-4" />
<span>{label}</span>
</button>
)
@ -409,7 +394,7 @@ function QuickAction({
function SectionHeader({ label, aside }: { label: string; aside?: string }) {
return (
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{label}
</span>
{aside && <span className="text-xs text-muted-foreground">{aside}</span>}
@ -425,20 +410,6 @@ function EmptyState({ text }: { text: string }) {
)
}
function FolderAvatar({ name, className }: { name: string; className?: string }) {
return (
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md text-[13px] font-bold',
avatarClass(name),
className,
)}
>
{name.charAt(0).toUpperCase() || '?'}
</div>
)
}
function FolderCard({
node,
actions,
@ -472,9 +443,8 @@ function FolderCard({
onOpenFolder(node.path)
}
}}
className="group flex w-full cursor-pointer items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
>
<FolderAvatar name={node.name} className="mt-0.5" />
<div className="min-w-0 flex-1">
{renameActive ? (
<RenameField
@ -489,28 +459,32 @@ function FolderCard({
{node.name}
</span>
)}
<div className="mt-0.5 text-xs text-muted-foreground">
{count} {count === 1 ? 'note' : 'notes'}
<div className="mt-0.5 flex min-w-0 items-baseline gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">
{count} {count === 1 ? 'note' : 'notes'}
</span>
{peek.length > 0 && (
<span className="truncate text-muted-foreground/70">
{peek.map((n) => (
<span key={n.path}>
<span className="text-muted-foreground/40">{' · '}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onOpenNote(n.path)
}}
className="transition-colors hover:text-foreground hover:underline"
>
{displayName(n)}
</button>
</span>
))}
</span>
)}
</div>
{peek.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{peek.map((n) => (
<button
key={n.path}
type="button"
onClick={(e) => {
e.stopPropagation()
onOpenNote(n.path)
}}
className="max-w-[200px] truncate rounded-full border border-border/60 bg-muted px-2.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{displayName(n)}
</button>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{modified}
</span>
@ -565,17 +539,6 @@ function FolderDetail({
return (
<>
<div className="mb-4 flex min-w-0 items-center gap-1.5 text-sm">
<button
type="button"
onClick={() => {
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
onNavigate(parent)
}}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Back"
>
<ArrowLeft className="size-4" />
</button>
<button
type="button"
onClick={() => onNavigate(null)}
@ -605,7 +568,7 @@ function FolderDetail({
{items.length === 0 ? (
<EmptyState text="This folder is empty." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
<div className="overflow-hidden rounded-xl border border-black/15 dark:border-border">
{items.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<ItemRow
@ -665,13 +628,6 @@ function ItemRow({
}}
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
>
{isDir ? (
<FolderAvatar name={node.name} />
) : (
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileText className="size-4" />
</div>
)}
<div className="min-w-0 flex-1">
{renameActive ? (
<RenameField
@ -682,16 +638,21 @@ function ItemRow({
onDone={onClearRename}
/>
) : (
<span className="block truncate text-sm text-foreground">{displayName(node)}</span>
)}
{isDir && (
<div className="mt-0.5 text-xs text-muted-foreground">
{count} {count === 1 ? 'note' : 'notes'}
</div>
<span className="block truncate text-sm font-semibold text-foreground">
{displayName(node)}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{isDir && (
<>
<span className="whitespace-nowrap">
{count} {count === 1 ? 'note' : 'notes'}
</span>
<span className="text-muted-foreground/40">·</span>
</>
)}
<span className="tabular-nums whitespace-nowrap">
{modified}
</span>
{isDir && (

View file

@ -13,7 +13,7 @@ import {
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
import { CompactConversation } from '@/components/compact-conversation'
export type OpenLiveNotePanelDetail = {
@ -659,37 +659,14 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
}
function LastRunTab({ live }: { live: LiveNote }) {
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loadingRun, setLoadingRun] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const runId = live.lastRunId ?? null
useEffect(() => {
if (!runId) {
setTranscript(null)
setFetchError(null)
setLoadingRun(false)
return
}
let cancelled = false
setLoadingRun(true)
setFetchError(null)
void (async () => {
try {
const t = await fetchAgentRunTranscript(runId)
if (cancelled) return
setTranscript(t)
} catch (err) {
if (cancelled) return
setFetchError(err instanceof Error ? err.message : String(err))
setTranscript(null)
} finally {
if (!cancelled) setLoadingRun(false)
}
})()
return () => { cancelled = true }
}, [runId])
// Live via the turns:events spine: an in-flight run's transcript streams
// in as the agent works; settled runs render from one snapshot fetch.
const {
transcript,
loading: loadingRun,
error: fetchError,
} = useAgentRunTranscript(runId)
if (!runId) {
return (

View file

@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
// How long the popup stays up before dismissing itself. The countdown (and
// its progress line) pauses while the pointer is over the popup — main only
// keeps a much longer crash-safety fallback.
const AUTO_DISMISS_MS = 30_000
type PopupPayload = {
title: string
message: string
hasCalendarEvent: boolean
}
// Rowboat sail glyph (black on transparent, same asset as the tray icon) —
// rendered on a white chip inside the "Take notes" pill.
const SAIL_ICON =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACfUlEQVR4nMSXO2gUURSGz8yuYuGjsBHFBza+UGwULUxjoY0iWPnA0lZQURDsFC1E7Owt7JQ0SZGQLk2SNoQEQhICCXlUCXk/ZvOf3HM2Z2/uZDM7m90fPnbm3jn3P/e5M0VqsorUZBWocYqogQnEhj1VzwTUsOTBOgJOg1WwYYMiyqdITDe9RK6CW+A2uAYugOPgBhiXuO3kal2E1pg5BO6BJ+A+uEK7OzcJpuVaR6amBArG+Bx4BZ6LqZUOdSI+I+SmoEJZEoilMTa+CN6Cl+CEMUrkuUgS5d91+e33OpApAQ06Cj6CN+CY1G14pqryPMt1b6jhagloI2z+EPwCl4xxgXabqnQXFCW+R8oTaxDvw5z1DbSL+Zpp2D4bpcTz7zAYMomVVaxizkP+FzwydYfJ9Uh3QskYWXGZrolOiamY/7QE1Pwk6AaXwTzoA7OghdyhQtJY7PVWzcnU/fPKK8xC5mdBB1gGv6UHY/IMr/rH4D25gyUtCS3n4efDaJ1Seuvf8xC/BhPgv1dnTz1+7qskogvSrhsu4wPqM/hCgeEPJUC0s99VBbkveWXa2A/wzktC45fILdxJL7kKM1+JV7cZCNRFyIYfwICXqA7/HzGPA22Ue5KmElWXjtYceCrXuiVXwDOp27OBPNJpaAVT5HaVTsVPcv98qb0nqs/7ABvw4XQTXJf7UfBCEkyqBeeV9u6UXHObvIsW9xOcNwFd2WfAHWnvO+iinUV5oAlo/ANyx3Yb+EQpez6keryS8QgMkjvp7oIFStnzIeX5LtAtyK9h3OOWrOZ5E9C/5PPk3gdnspoT5Z8Ce2xnNtegeqgmc1YjP82CavrHadMT2AIAAP//JKuLxQAAAAZJREFUAwAJ75pCeqjxVQAAAABJRU5ErkJggg=='
/**
* Content of the "Meeting detected" always-on-top popup panel (see
* `showMeetingPopup` in the main process). Granola-style consent prompt
* a lean horizontal bar: dashed rule (ad-hoc) or solid accent (calendar
* match), "Meeting detected" + platform, and a "Take notes" pill. The ×
* floats on the card's top-left corner; the window itself is transparent.
* Detection never records the user has to click "Take notes".
*/
// Opened directly in a browser (vite only, no Electron) there is no IPC
// bridge — render sample data so the popup can be styled with plain HMR.
// `?variant=calendar` previews the calendar-linked look.
const isPreview = typeof window.ipc === 'undefined'
export function MeetingDetectedPopup() {
const [payload, setPayload] = useState<PopupPayload | null>(null)
useEffect(() => {
if (isPreview) {
const calendar = new URLSearchParams(window.location.search).get('variant') === 'calendar'
setPayload(
calendar
? { title: 'Weekly design sync', message: 'Chrome', hasCalendarEvent: true }
: { title: 'Meeting detected', message: 'Chrome', hasCalendarEvent: false },
)
return
}
const cleanup = window.ipc.on('meetingDetect:payload', (next) => setPayload(next))
// The main process pushes on did-finish-load, but that can race this
// listener's registration — fetch explicitly too.
window.ipc
.invoke('meetingDetect:getPayload', null)
.then(({ payload: cached }) => {
if (cached) setPayload(cached)
})
.catch(() => {})
return cleanup
}, [])
const act = useCallback((action: 'take-notes' | 'dismiss') => {
if (isPreview) {
console.log(`[preview] action: ${action}`)
return
}
void window.ipc.invoke('meetingDetect:action', { action }).catch(() => {})
}, [])
// Subtle arrival chime, synthesized (no asset, no process spawn): a soft
// sine ding gliding up a fourth, ~0.35s, low gain. Once per popup.
const chimedRef = useRef(false)
useEffect(() => {
if (!payload || chimedRef.current || isPreview) return
chimedRef.current = true
try {
const ctx = new AudioContext()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(1174.66, ctx.currentTime + 0.08)
gain.gain.setValueAtTime(0.0001, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.06, ctx.currentTime + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.4)
osc.onended = () => void ctx.close()
} catch { /* audio is best-effort */ }
}, [payload])
// Auto-dismiss countdown, rendered as the progress line at the bottom.
// Hovering pauses it (elapsed only accrues while the pointer is away).
const [remainingFrac, setRemainingFrac] = useState(1)
const hoveredRef = useRef(false)
useEffect(() => {
const TICK_MS = 100
let elapsed = 0
const timer = setInterval(() => {
if (hoveredRef.current) return
elapsed += TICK_MS
const frac = Math.max(0, 1 - elapsed / AUTO_DISMISS_MS)
setRemainingFrac(frac)
if (frac <= 0) {
clearInterval(timer)
act('dismiss')
}
}, TICK_MS)
return () => clearInterval(timer)
}, [act])
// The Electron window IS the card (376×48, card background, native macOS
// rounded corners + shadow — no transparency, which panels don't honor).
// The preview emulates that frame with its own rounding/shadow.
// No drag region: draggable areas swallow mouse events, which would keep
// the hover-revealed × from ever showing while over the card body.
const popup = (
<div
className={`group relative flex items-center gap-3 pl-4 pr-2 bg-[#1d1d1d] overflow-hidden ${
isPreview ? 'rounded-xl shadow-[0_8px_28px_rgba(0,0,0,0.45)]' : 'h-screen w-screen'
}`}
style={isPreview ? { width: 376, height: 48 } : undefined}
onMouseEnter={() => { hoveredRef.current = true }}
onMouseLeave={() => { hoveredRef.current = false }}
>
{/* Close — top-left corner, revealed on hover */}
<button
onClick={() => act('dismiss')}
className="absolute left-1 top-1 z-10 flex size-5 items-center justify-center rounded-full bg-neutral-800 border border-neutral-600 text-neutral-200 shadow-md hover:bg-neutral-700 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Dismiss"
>
<X className="size-3" strokeWidth={2.5} />
</button>
<div className="flex-1 min-w-0 transition-[padding] group-hover:pl-6">
<div className="text-sm font-semibold text-white leading-tight truncate">
{payload?.title ?? 'Meeting detected'}
</div>
<div className="text-[13px] text-neutral-400 leading-tight truncate mt-0.5">
{payload?.message ?? ''}
</div>
</div>
<button
onClick={() => act('take-notes')}
className="flex h-8.5 shrink-0 items-center gap-1.5 rounded-lg bg-neutral-800/90 border border-neutral-700 pl-1.5 pr-2.5 hover:bg-neutral-700 transition-colors"
>
<span className="flex size-6 items-center justify-center">
<img src={SAIL_ICON} alt="" className="size-4.5 invert" />
</span>
<span className="text-[13px] font-semibold text-white">Take notes</span>
</button>
{/* Time-left line: drains toward dismissal, frozen while hovered */}
<div
className="absolute bottom-0 left-0 h-[2px] bg-white/25 transition-[width] duration-100 ease-linear"
style={{ width: `${remainingFrac * 100}%` }}
/>
</div>
)
if (!isPreview) return popup
return (
<div className="min-h-screen w-full bg-gradient-to-br from-sky-200 via-indigo-200 to-rose-200 p-6">
{popup}
</div>
)
}

View file

@ -711,8 +711,8 @@ function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void })
const todayKey = localDateKey(now)
return (
<section className="border-b border-border/60 px-6 pb-6 pt-5">
<div className="mx-auto w-full max-w-[760px]">
<section className="border-b border-border/60 pb-6 pt-5">
<div className="w-full">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
@ -1243,13 +1243,10 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
const isRecording = meetingState === 'recording'
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Mic className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Meetings</h2>
</div>
<h2 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Meetings</h2>
<Button
type="button"
size="sm"
@ -1267,13 +1264,14 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
{meetingSummarizing ? 'Generating notes...' : getMeetingButtonLabel(meetingState)}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
Upcoming events and meeting notes.
</p>
</div>
<div className="flex-1 overflow-auto">
<div className="mx-auto w-full max-w-[1120px] px-[30px] pb-12">
<UpcomingEvents onOpenNote={onOpenNote} />
<div className="p-6">
<div className="pt-6">
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
@ -1329,6 +1327,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
</div>
)}
</div>
</div>
</div>
</div>
)

View file

@ -75,6 +75,38 @@ export function SlackIcon({ className }: IconProps) {
)
}
export function GitHubIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
export function DiscordIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" fill="#5865F2" />
</svg>
)
}
export function WhatsAppIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" fill="#25D366" />
</svg>
)
}
export function TelegramIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" fill="#26A5E4" />
</svg>
)
}
export function FirefliesIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>

View file

@ -42,14 +42,11 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
} = state
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
// Hosted providers (openai/anthropic/google) get a default model, so we only
// ask for a model on providers that truly need one (local/custom/gateway),
// or as a fallback if no model is set yet.
// Hosted providers (openai/anthropic/google) fetch their models from the API
// key on test, so they never need a manual model field. Only local/custom/
// gateway providers, where the user must specify a model, show the input.
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
const showModelInput = !hostedProviders.includes(llmProvider)
// Connect-only, mirroring Settings: entering a key (or base URL) is enough
// and the model is resolved silently at save. openai-compatible is the sole
// exception with a visible Model field — its /models endpoint often doesn't
// exist, so a typed value must be able to win.
const showModelInput = llmProvider === "openai-compatible"
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
const isSelected = llmProvider === provider.id
@ -150,9 +147,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
{/* Provider configuration */}
<div className="space-y-4">
{/* Cloud providers get a default model auto-selected; only local/custom
providers (no catalog) need a model here. Users can pick any of the
provider's models later in the chat view. */}
{/* Every provider resolves its model silently at save. openai-compatible
alone keeps this field, since its /models is unreliable and a typed
value must win; leaving it blank auto-selects from the fetched list. */}
{showModelInput && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
@ -167,7 +164,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Enter model"
placeholder="Model ID (leave empty to auto-select)"
/>
)}
{modelsError && (

View file

@ -457,13 +457,24 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
return false
}
const preferred = preferredDefaults[llmProvider]
// A model the user explicitly entered always wins — this used to prefer
// catalog[0], which silently replaced the user's Ollama model with
// whatever model the local server happened to list first.
const model = modelInputShown
? (typed || catalog[0] || "")
: ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "")
// Resolve the model silently — same precedence as the settings dialog's
// resolvedModel, so onboarding no longer asks users to type one.
// openai-compatible keeps a visible Model field (its /models is
// unreliable), so a typed value there wins; otherwise the typed/saved
// model if the fetched list still has it, else the flavor's preferred
// default, else the first fetched id. An empty list falls back to
// whatever was typed/seeded.
let model: string
if (llmProvider === "openai-compatible" && typed) {
model = typed
} else if (catalog.length > 0) {
const preferred = preferredDefaults[llmProvider]
model = (typed && catalog.includes(typed))
? typed
: ((preferred && catalog.includes(preferred)) ? preferred : catalog[0])
} else {
model = typed
}
// `models` is the user's curated assistant-model list (shown in Settings),
// NOT the full provider catalog. Onboarding seeds it with just the selected

View file

@ -6,7 +6,6 @@ import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command'
@ -134,6 +133,10 @@ export function CommandPalette({
const knowledgeResults = results.filter(r => r.type === 'knowledge')
const chatResults = results.filter(r => r.type === 'chat')
const scope: SearchType = activeTypes.has('knowledge') ? 'knowledge' : 'chat'
const otherScope: SearchType = scope === 'knowledge' ? 'chat' : 'knowledge'
const scopeLabel = scope === 'knowledge' ? 'knowledge' : 'chats'
return (
<CommandDialog
open={open}
@ -145,30 +148,54 @@ export function CommandPalette({
>
<CommandInput
ref={searchInputRef}
placeholder="Search..."
placeholder={scope === 'knowledge' ? 'Search notes and files…' : 'Search chats…'}
value={query}
onValueChange={setQuery}
onKeyDown={(e) => {
if (e.key === 'Tab') {
e.preventDefault()
toggleType(otherScope)
}
}}
/>
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<FilterToggle
active={activeTypes.has('knowledge')}
onClick={() => toggleType('knowledge')}
icon={<FileTextIcon className="size-3" />}
label="Knowledge"
/>
<FilterToggle
active={activeTypes.has('chat')}
onClick={() => toggleType('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chats"
/>
<div className="flex items-center px-3 py-2">
<div className="inline-flex items-center rounded-lg bg-muted/60 p-0.5">
<FilterToggle
active={scope === 'knowledge'}
onClick={() => toggleType('knowledge')}
icon={<FileTextIcon className="size-3" />}
label="Knowledge"
/>
<FilterToggle
active={scope === 'chat'}
onClick={() => toggleType('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chats"
/>
</div>
</div>
<CommandList>
{!query.trim() && (
<CommandEmpty>Type to search...</CommandEmpty>
<div className="px-6 py-10 text-center">
<p className="text-sm text-muted-foreground">
{scope === 'knowledge' ? 'Search your notes and files' : 'Search your chat history'}
</p>
</div>
)}
{query.trim() && isSearching && results.length === 0 && (
<div className="px-6 py-10 text-center text-sm text-muted-foreground">Searching</div>
)}
{query.trim() && !isSearching && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
<div className="px-6 py-10 text-center">
<p className="text-sm text-muted-foreground">No matches in {scopeLabel}.</p>
<button
type="button"
onClick={() => toggleType(otherScope)}
className="mt-1.5 text-xs text-primary hover:underline"
>
Search {otherScope === 'knowledge' ? 'knowledge' : 'chats'} instead
</button>
</div>
)}
{knowledgeResults.length > 0 && (
<CommandGroup heading="Knowledge">
@ -205,10 +232,24 @@ export function CommandPalette({
</CommandGroup>
)}
</CommandList>
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1"><Kbd></Kbd> Navigate</span>
<span className="flex items-center gap-1"><Kbd></Kbd> Open</span>
<span className="flex items-center gap-1"><Kbd>Tab</Kbd> Switch scope</span>
<span className="ml-auto flex items-center gap-1"><Kbd>esc</Kbd> Close</span>
</div>
</CommandDialog>
)
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-border bg-muted px-1 py-px font-mono text-[10px] text-muted-foreground">
{children}
</kbd>
)
}
function FilterToggle({
active,
onClick,
@ -224,10 +265,10 @@ function FilterToggle({
<button
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
import {
Dialog,
@ -23,12 +23,14 @@ import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
import { useProviderModels } from "@/hooks/use-provider-models"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
@ -113,6 +115,13 @@ const tabs: TabConfig[] = [
},
]
/** Sidebar nav grouping: identity first, capabilities, then app-level. */
const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [
{ label: null, ids: ["account", "connections", "mobile"] },
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging"] },
{ label: "App", ids: ["appearance", "notifications", "help"] },
]
interface SettingsDialogProps {
/** Optional trigger element. Omit when controlling `open` externally. */
children?: React.ReactNode
@ -265,9 +274,7 @@ function HelpSettings() {
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<GitHubIcon className="size-5 shrink-0" />
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
@ -278,9 +285,7 @@ function HelpSettings() {
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<DiscordIcon className="size-5 shrink-0" />
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">Chat with the community</span>
@ -291,9 +296,7 @@ function HelpSettings() {
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<Mail className="size-5 shrink-0" />
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Contact us</span>
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
@ -353,11 +356,48 @@ function ThemeOption({
)
}
function LaunchAtLoginSetting() {
const [openAtLogin, setOpenAtLogin] = useState(false)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
window.ipc.invoke("app:getLoginItemSettings", null)
.then(({ openAtLogin }) => setOpenAtLogin(openAtLogin))
.catch(() => { /* dev builds report off */ })
.finally(() => setLoaded(true))
}, [])
const handleToggle = async (next: boolean) => {
setOpenAtLogin(next)
try {
await window.ipc.invoke("app:setLoginItemSettings", { openAtLogin: next })
} catch {
setOpenAtLogin(!next)
}
}
return (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Start Rowboat when you log in</div>
<div className="text-xs text-muted-foreground mt-0.5">
Keeps Rowboat in your menu bar so meeting notes and notifications work without opening the app
</div>
</div>
<Switch checked={openAtLogin} onCheckedChange={handleToggle} disabled={!loaded} />
</div>
)
}
function AppearanceSettings() {
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium mb-3">System</h4>
<LaunchAtLoginSetting />
</div>
<div>
<h4 className="text-sm font-medium mb-3">Theme</h4>
<p className="text-xs text-muted-foreground mb-4">
@ -442,17 +482,17 @@ interface LlmModelOption {
release_date?: string
}
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openai", name: "OpenAI", description: "GPT models" },
{ id: "anthropic", name: "Anthropic", description: "Claude models" },
{ id: "google", name: "Gemini", description: "Google AI Studio" },
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally" },
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key" },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway" },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API" },
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
]
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
@ -463,6 +503,7 @@ const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
aigateway: "https://ai-gateway.vercel.sh/v1",
}
type ProviderModelConfig = {
@ -478,18 +519,20 @@ type ProviderModelConfig = {
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
// Flavors present in the saved providers map — drives each card's
// "Connected" indicator, independent of which card is active.
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
@ -498,8 +541,18 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
// auto-enable) has ever set it, so we only auto-enable once.
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
const [deferExplicit, setDeferExplicit] = useState(false)
// openai-compatible only: free-text model that takes precedence over the
// fetched list (many such servers don't implement /models at all).
const [customModel, setCustomModel] = useState("")
const activeConfig = providerConfigs[provider]
// Live per-key model list for the active provider — drives the primary
// model area. The per-function fields below still use the static catalog.
const providerModels = useProviderModels({
flavor: provider,
apiKey: activeConfig.apiKey,
baseURL: activeConfig.baseURL,
})
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
@ -510,8 +563,28 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
// Settings no longer exposes model selection — the model is resolved
// silently when the user connects (the config schema still requires one;
// background agents/channels read it). Precedence: a typed escape-hatch
// value (openai-compatible Model field, offline manual input) > the saved
// model when the fetched list still has it > the flavor's preferred
// default > the first fetched id.
const resolvedModel = (() => {
if (provider === "openai-compatible" && customModel.trim()) return customModel.trim()
const saved = primaryModel.trim()
if (providerModels.status === "loaded" && providerModels.models.length > 0) {
if (saved && providerModels.models.includes(saved)) return saved
const preferred = preferredDefaults[provider]
if (preferred && providerModels.models.includes(preferred)) return preferred
return providerModels.models[0]
}
return saved
})()
// Gate Connect on credentials only, NOT on resolvedModel — that derives
// from the live fetch, which hasn't settled the instant a key is pasted, so
// gating on it made the first click a no-op (the model is resolved, fetching
// on demand if needed, inside handleTestAndSave).
const canTest =
primaryModel.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
@ -526,6 +599,19 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
[]
)
// All primary-model writes go through here: the old `models: [value]` form
// silently dropped every saved model after the first.
const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => {
setProviderConfigs(prev => {
const existing = prev[prov].models
return {
...prev,
[prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] },
}
})
setTestState({ status: "idle" })
}, [])
// Load current config from file
useEffect(() => {
@ -542,6 +628,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
const parsed = JSON.parse(result.data)
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
setSavedProviders(new Set(
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
))
if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor)
@ -616,7 +706,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
async function loadModels() {
try {
setModelsLoading(true)
setModelsError(null)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const p of result.providers || []) {
@ -624,7 +713,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}
setModelsCatalog(catalog)
} catch {
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
@ -634,30 +722,49 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
loadModels()
}, [dialogOpen])
// Set default models from catalog when catalog loads
// A saved openai-compatible model that the server's list doesn't confirm
// (not listed, or /models unreachable) belongs in the visible Model field,
// where it stays editable and wins over the silent pick.
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
setProviderConfigs(prev => {
const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const prov of cloudProviders) {
const catalog = modelsCatalog[prov]
if (catalog?.length && !next[prov].models[0]) {
const preferred = preferredDefaults[prov]
const hasPreferred = preferred && catalog.some(m => m.id === preferred)
const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "")
next[prov] = { ...next[prov], models: [defaultModel] }
}
}
return next
})
}, [modelsCatalog])
if (provider !== "openai-compatible" || customModel || !primaryModel) return
if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) {
setCustomModel(primaryModel)
}
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
const handleTestAndSave = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
// Normally providerModels has loaded by click time and resolvedModel is
// set. But a Connect click right after pasting a key can beat the
// debounced key-change fetch — so when nothing is resolved yet, fetch
// the list on demand (one fetch, then save) instead of forcing a second
// click. Same silent precedence as resolvedModel, minus the customModel
// branch (that path only runs when resolvedModel is already empty).
let model = resolvedModel
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
})
if (listRes.success && listRes.models && listRes.models.length > 0) {
const preferred = preferredDefaults[provider]
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
}
}
if (!model) {
setTestState({ status: "error", error: "Enter a model to connect" })
toast.error("Enter a model to connect")
return
}
// The silently resolved model takes the primary slot; the rest of the
// saved list is preserved (same semantics setPrimaryModel had).
const existing = activeConfig.models.map(m => m.trim())
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
const providerConfig = {
provider: {
flavor: provider,
@ -677,6 +784,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
if (result.success) {
await window.ipc.invoke("models:saveConfig", providerConfig)
setDefaultProvider(provider)
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], models: allModels },
}))
setSavedProviders(prev => new Set(prev).add(provider))
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
// Local models compete with background agents for the same hardware:
@ -704,7 +816,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
@ -735,12 +847,64 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}, [providerConfigs, rowboatConnected])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
const isDefaultProv = defaultProvider === prov
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(result.data)
let parsed = JSON.parse(result.data)
// Disconnecting the default provider: silently promote another
// connected provider first — the connect-only UI has no usable
// set-as-default step to send the user to. Prefer the provider the
// user's explicit defaultSelection points at; promotion goes through
// the same models:saveConfig path Set-as-default uses, so the repo
// writes a valid top-level provider/model.
let promoted: LlmProviderFlavor | null = null
if (isDefaultProv) {
const selProvider = parsed?.defaultSelection?.provider
const candidates = Object.keys(parsed?.providers ?? {})
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
.sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0))
for (const candidate of candidates) {
const config = providerConfigs[candidate]
const allModels = config.models.map(m => m.trim()).filter(Boolean)
// Same silent precedence as connect, minus the live list we don't
// have here: the provider's saved model, else its preferred default.
const model = allModels[0] || preferredDefaults[candidate] || ""
if (!model) continue
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: candidate,
apiKey: config.apiKey.trim() || undefined,
baseURL: config.baseURL.trim() || undefined,
},
model,
models: allModels.length > 0 ? allModels : [model],
...(rowboatConnected ? {} : {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}),
})
promoted = candidate
break
}
if (promoted) {
// saveConfig rewrote top-level and the providers map — re-read so
// the deletion write below doesn't clobber the promotion.
const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
parsed = JSON.parse(fresh.data)
}
}
if (parsed?.providers?.[prov]) {
delete parsed.providers[prov]
}
// A defaultSelection pointing at the removed provider is dangling —
// drop it so llm:getDefaultModel falls back cleanly.
if (parsed?.defaultSelection?.provider === prov) {
delete parsed.defaultSelection
}
// If the deleted provider is the current top-level active one,
// switch top-level config to the current default provider
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
@ -759,6 +923,24 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
}
} else if (parsed?.provider?.flavor === prov) {
// Removing the last connected provider: drop the top-level pair
// entirely. The schema requires it, so core's readConfig() treats
// the file as "no config" — signed-in falls back to the curated
// gateway default and signed-out llm:getDefaultModel rejects, which
// the composer already handles (it shows the connect hint).
delete parsed.provider
delete parsed.model
delete parsed.models
delete parsed.knowledgeGraphModel
delete parsed.meetingNotesModel
delete parsed.liveNoteAgentModel
delete parsed.autoPermissionDecisionModel
// With no BYOK providers left, any non-gateway selection is dangling.
if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat"
&& Object.keys(parsed?.providers ?? {}).length === 0) {
delete parsed.defaultSelection
}
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -768,17 +950,29 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
}))
setSavedProviders(prev => {
const next = new Set(prev)
next.delete(prov)
return next
})
if (isDefaultProv) setDefaultProvider(promoted)
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Provider configuration removed")
if (promoted) {
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
toast.success(`Disconnected · ${promotedName} is now the default`)
} else {
toast.success("Provider configuration removed")
}
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, providerConfigs, rowboatConnected])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
const isDefault = defaultProvider === p.id
const isSelected = provider === p.id
const isConnected = savedProviders.has(p.id)
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
return (
<button
@ -794,13 +988,19 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
: "border-border hover:bg-accent"
)}
>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
<p.icon className="size-4 shrink-0" />
<span className="text-sm font-medium">{p.name}</span>
{isDefault && !rowboatConnected && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
)}
{isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
@ -864,48 +1064,117 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
)}
</div>
{/* Model selection - side by side */}
<div className="grid grid-cols-2 gap-3">
{/* Assistant models (left column) */}
{/* API Key — key-first: the model list is fetched from it */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : (
<div className="space-y-2">
{showModelInput ? (
<Input
value={primaryModel}
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
placeholder="Enter model"
/>
) : (
<Select
value={primaryModel}
onValueChange={(value) => updateConfig(provider, { models: [value] })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder={
provider === "ollama"
? "http://localhost:11434"
: provider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
{/* Connection status — the model itself is resolved silently on save */}
<div className="space-y-2">
{providerModels.status === "idle" ? (
<div className="text-sm text-muted-foreground">
{isLocalProvider
? "Enter your base URL to connect"
: "Enter your API key to connect"}
</div>
) : providerModels.status === "loading" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking connection
</div>
) : providerModels.status === "error" ? (
<div className="space-y-2">
<div className="text-xs text-destructive break-words">
{providerModels.error || "Connection check failed"}
</div>
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
Retry
</Button>
{provider !== "openai-compatible" && (
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter a model to connect anyway"
/>
)}
</div>
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
Connected, but the provider reported no models enter one manually
</div>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter model"
/>
</div>
) : (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
</div>
)}
{savedProviders.has(provider) && (
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDeleteProvider(provider)}
>
Disconnect
</Button>
)}
</div>
{/* openai-compatible escape hatch: its /models often doesn't exist, and
a typed model always wins over the silent pick */}
{provider === "openai-compatible" && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
<Input
value={customModel}
onChange={(e) => {
setCustomModel(e.target.value)
setPrimaryModel(provider, e.target.value)
}}
placeholder="Model ID (leave empty to auto-select)"
/>
</div>
)}
{/* Per-function model overrides */}
<div className="grid grid-cols-2 gap-3">
{!rowboatConnected && (<>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
@ -1045,39 +1314,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
</>)}
</div>
{/* API Key */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
placeholder={
provider === "ollama"
? "http://localhost:11434"
: provider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">
@ -1109,9 +1345,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
) : (
"Test & Save"
"Connect"
)}
</Button>
</div>
@ -1998,7 +2234,11 @@ function AgentStatusRow({
const ready = installed && status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
{agent === 'claude' ? (
<AnthropicIcon className="size-5 shrink-0" />
) : (
<OpenAIIcon className="size-5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
@ -2234,7 +2474,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
// --- Notification Settings ---
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task"
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" | "meeting_detection" | "meeting_notes_ready"
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
{
@ -2257,6 +2497,16 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de
label: "Background agents",
description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.",
},
{
key: "meeting_detection",
label: "Meeting detection",
description: "A popup offering to take notes when Rowboat notices you're in a call or meeting. Nothing records until you accept.",
},
{
key: "meeting_notes_ready",
label: "Meeting notes ready",
description: "When your meeting notes finish generating after a call. Click to open the note. Only shown while the app is in the background.",
},
]
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
@ -2452,34 +2702,47 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
<div className="px-2 py-3 mb-2">
<h2 className="font-semibold text-sm">Settings</h2>
<div className="px-2 pt-3.5 pb-3 mb-2">
<h2 className="font-semibold text-base tracking-tight">Settings</h2>
</div>
<nav className="flex flex-col gap-1">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
"flex items-center gap-2 px-2 py-2 rounded-md text-sm transition-colors text-left",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
<tab.icon className="size-4" />
{tab.label}
</button>
))}
<nav className="flex flex-col">
{NAV_SECTIONS.map((section) => {
const sectionTabs = visibleTabs.filter((tab) => section.ids.includes(tab.id))
if (sectionTabs.length === 0) return null
return (
<div key={section.label ?? "main"} className="flex flex-col gap-0.5">
{section.label ? (
<div className="px-2 pb-1 pt-4 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.label}
</div>
) : null}
{sectionTabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors text-left",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
<tab.icon className="size-4" />
{tab.label}
</button>
))}
</div>
)
})}
</nav>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 min-h-0">
{/* Header */}
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
<div className="px-6 pb-4 pt-5">
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
<p className="text-sm text-muted-foreground mt-1">
{activeTab === "models" && rowboatConnected
? "Select your default models"
: activeTabConfig.description}
@ -2487,7 +2750,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (

View file

@ -1,9 +1,9 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
import { Loader2, Calendar } from "lucide-react"
import { FirefliesIcon, GoogleIcon, SlackIcon } from "@/components/onboarding/provider-icons"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
@ -47,9 +47,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
{icon}
</div>
{icon}
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{displayName}</span>
{state.isLoading ? (
@ -145,9 +143,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{c.useComposioForGoogle ? (
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<GoogleIcon className="size-5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Gmail</span>
{c.gmailLoading ? (
@ -189,14 +185,12 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
</div>
</div>
) : (
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <GoogleIcon className="size-5" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Calendar className="size-4" />
</div>
<Calendar className="size-5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{c.googleCalendarLoading ? (
@ -238,7 +232,6 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
</div>
</div>
)}
<Separator className="my-2" />
</>
)}
@ -252,14 +245,13 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
</div>
{/* Fireflies */}
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
{renderOAuthProvider('fireflies-ai', 'Fireflies', <FirefliesIcon className="size-5" />, 'AI meeting transcripts')}
</>
)}
{/* Team Communication Section */}
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<div className="px-3 pt-3 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
@ -267,9 +259,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<SlackIcon className="size-5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackLoading ? (
@ -408,8 +398,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{/* Knowledge Sources Section */}
{c.slackEnabled && (
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<div className="px-3 pt-3 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Knowledge Sources
</span>
@ -417,9 +406,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<SlackIcon className="size-5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack to knowledge</span>
<span className="text-xs text-muted-foreground truncate">

View file

@ -2,10 +2,10 @@
import { useCallback, useEffect, useState } from "react"
import type { z } from "zod"
import { Coffee, Loader2, MessageCircle, Send, Smartphone } from "lucide-react"
import { Coffee, Loader2, Smartphone } from "lucide-react"
import { TelegramIcon, WhatsAppIcon } from "@/components/onboarding/provider-icons"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { toast } from "sonner"
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/src/channels.js"
@ -104,9 +104,7 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
{/* Caffeinate */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Coffee className="size-4" />
</div>
<Coffee className="size-5 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Caffeinate</span>
<span className="text-xs text-muted-foreground">
@ -131,15 +129,12 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
/>
</div>
<Separator />
{/* WhatsApp */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageCircle className="size-4" />
</div>
<WhatsAppIcon className="size-5 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">WhatsApp</span>
<span className="text-xs text-muted-foreground">
@ -228,15 +223,12 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
)}
</div>
<Separator />
{/* Telegram */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Send className="size-4" />
</div>
<TelegramIcon className="size-5 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Telegram</span>
<span className="text-xs text-muted-foreground">

View file

@ -15,7 +15,11 @@ import {
Home,
LayoutGrid,
Mic,
MoreVertical,
Pencil,
Pin,
SquarePen,
Trash2,
Plug,
LoaderIcon,
Mail,
@ -61,6 +65,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
import { SettingsDialog } from "@/components/settings-dialog"
@ -127,6 +137,8 @@ type ServiceEventType = z.infer<typeof ServiceEvent>
const MAX_SYNC_EVENTS = 1000
const RUN_STALE_MS = 2 * 60 * 60 * 1000
const PINNED_CHATS_STORAGE_KEY = 'x:pinned-chats'
const MAX_PINNED_CHATS = 3
const SERVICE_LABELS: Record<string, string> = {
gmail: "Syncing Gmail",
@ -171,6 +183,10 @@ type SidebarContentPanelProps = {
onOpenAgent?: (slug: string) => void
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
onOpenRun?: (runId: string) => void
/** Persist a custom chat title (sessions:setTitle) and refresh the runs list. */
onRenameRun?: (runId: string, title: string) => void
/** Delete the chat's session (sessions:delete) and refresh the runs list. */
onDeleteRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenEmail?: (threadId?: string) => void
onOpenHome?: () => void
@ -422,6 +438,8 @@ export function SidebarContentPanel({
onOpenApps,
recentRuns = [],
onOpenRun,
onRenameRun,
onDeleteRun,
onOpenChatHistory,
onOpenEmail,
onOpenHome,
@ -548,16 +566,57 @@ export function SidebarContentPanel({
.slice(0, 10)
}, [tree])
// Chats: the 5 most recently modified chats, newest first.
// Pinned chats: a per-machine UI preference, persisted in localStorage.
const [pinnedChatIds, setPinnedChatIds] = useState<string[]>(() => {
try {
const raw = window.localStorage.getItem(PINNED_CHATS_STORAGE_KEY)
const parsed: unknown = raw ? JSON.parse(raw) : []
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
})
const toggleChatPin = useCallback((chatId: string) => {
const isPinned = pinnedChatIds.includes(chatId)
// Count only pins that still resolve to a chat — deleted chats leave
// stale ids in localStorage and must not eat pin slots.
const activePinCount = pinnedChatIds.filter((id) => recentRuns.some((r) => r.id === id)).length
if (!isPinned && activePinCount >= MAX_PINNED_CHATS) {
toast(`You can pin up to ${MAX_PINNED_CHATS} chats`, 'error')
return
}
const next = isPinned ? pinnedChatIds.filter((id) => id !== chatId) : [...pinnedChatIds, chatId]
try {
window.localStorage.setItem(PINNED_CHATS_STORAGE_KEY, JSON.stringify(next))
} catch { /* ignore */ }
setPinnedChatIds(next)
}, [pinnedChatIds, recentRuns])
// Chats: pinned first, then the most recently modified, 10 rows total.
const recentChats = React.useMemo(() => {
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
return [...recentRuns]
.sort((a, b) => chatRecency(b) - chatRecency(a))
.slice(0, 10)
}, [recentRuns])
const sorted = [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a))
const pinned = sorted.filter((r) => pinnedChatIds.includes(r.id))
const rest = sorted.filter((r) => !pinnedChatIds.includes(r.id))
return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))]
}, [recentRuns, pinnedChatIds])
// Chat pending delete confirmation, if any.
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
// Inline chat rename: which row is editing and its draft text.
const [renamingChatId, setRenamingChatId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const commitChatRename = useCallback((chatId: string) => {
const title = renameDraft.trim()
const current = recentChats.find((c) => c.id === chatId)
setRenamingChatId(null)
if (!title || title === (current?.title ?? '')) return
onRenameRun?.(chatId, title)
}, [renameDraft, recentChats, onRenameRun])
// Workspace count for the Workspaces sublabel — top-level dir children of
// knowledge/Workspace (matches WorkspaceView's root listing).
@ -737,9 +796,16 @@ export function SidebarContentPanel({
{/* Top spacer to clear the traffic lights + fixed toggle row */}
<div className="h-8" />
{/* Quick actions */}
<div className="titlebar-no-drag flex items-center gap-1.5 px-3 pb-2">
<div className="titlebar-no-drag flex items-center gap-1 pl-3 pr-6 pb-2">
{onNewChat && (
<ActionButton icon={SquarePen} label="New chat" onClick={onNewChat} />
<button
type="button"
onClick={onNewChat}
className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md border border-sidebar-border text-[13px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<SquarePen className="size-3.5" />
New chat
</button>
)}
<ActionButton icon={FilePlus} label="New note" onClick={() => knowledgeActions.createNote()} />
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} variant="action" />
@ -764,9 +830,9 @@ export function SidebarContentPanel({
data-tour-id="nav-email"
isActive={activeNav === 'email'}
onClick={() => onOpenEmail?.()}
className={previewEmail ? 'h-auto py-1.5' : undefined}
className={previewEmail ? 'h-auto items-start py-1.5' : undefined}
>
<Mail className="size-4 shrink-0" />
<Mail className={cn('size-4 shrink-0', previewEmail && 'mt-0.5')} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Email</span>
{previewEmail && (
@ -782,14 +848,22 @@ export function SidebarContentPanel({
)}
</SidebarMenuButton>
</SidebarMenuItem>
{codeModeEnabled && (
<SidebarMenuItem>
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
<Code2 className="size-4 shrink-0" />
<span className="flex-1 truncate">Code</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-meetings"
isActive={activeNav === 'meetings'}
onClick={onOpenMeetings}
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
className={meetingSublabel ? 'h-auto items-start py-1.5' : undefined}
>
<Mic className={cn('size-4 shrink-0', meetingIsRecording && 'text-red-500')} />
<Mic className={cn('size-4 shrink-0', meetingSublabel && 'mt-1', meetingIsRecording && 'text-red-500')} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Meetings</span>
{meetingSublabel && (
@ -861,22 +935,14 @@ export function SidebarContentPanel({
</div>
) : null}
</SidebarMenuItem>
{codeModeEnabled && (
<SidebarMenuItem>
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
<Code2 className="size-4 shrink-0" />
<span className="flex-1 truncate">Code</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-knowledge"
isActive={activeNav === 'knowledge'}
onClick={() => knowledgeActions.openKnowledgeView()}
className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined}
className={knowledgeUpdatedLabel ? 'h-auto items-start py-1.5' : undefined}
>
<FileText className="size-4 shrink-0" />
<FileText className={cn('size-4 shrink-0', knowledgeUpdatedLabel && 'mt-0.5')} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Brain</span>
{knowledgeUpdatedLabel && (
@ -890,16 +956,26 @@ export function SidebarContentPanel({
<div className="mx-3 my-2 border-t border-sidebar-border" />
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-apps"
isActive={activeNav === 'apps'}
onClick={onOpenApps}
>
<LayoutGrid className="size-4 shrink-0" />
<span className="flex-1 truncate">Apps</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-agents"
isActive={activeNav === 'agents'}
onClick={onOpenBgTasks}
className={bgAgentsLabel ? 'h-auto py-1.5' : undefined}
className={bgAgentsLabel ? 'h-auto items-start py-1.5' : undefined}
>
<Bot className="size-4 shrink-0 text-muted-foreground" />
<Bot className={cn('size-4 shrink-0', bgAgentsLabel && 'mt-0.5')} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-muted-foreground">Background agents</span>
<span className="truncate">Background agents</span>
{bgAgentsLabel && (
<span className={cn(
'truncate text-[11px]',
@ -911,26 +987,16 @@ export function SidebarContentPanel({
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-apps"
isActive={activeNav === 'apps'}
onClick={onOpenApps}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-muted-foreground">Apps</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-workspaces"
isActive={activeNav === 'workspaces'}
onClick={() => knowledgeActions.openWorkspaceAt()}
className="h-auto py-1.5"
className="h-auto items-start py-1.5"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<Folder className="mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-muted-foreground">Workspaces</span>
<span className="truncate">Workspaces</span>
<span className="truncate text-[11px] text-muted-foreground">
{workspaceCount === 0 ? 'No workspaces' : `${workspaceCount} workspace${workspaceCount === 1 ? '' : 's'}`}
</span>
@ -964,10 +1030,75 @@ export function SidebarContentPanel({
<SidebarMenu>
{recentChats.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
</SidebarMenuButton>
{renamingChatId === chat.id ? (
<div className="flex h-8 items-center gap-2 rounded-md px-2">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitChatRename(chat.id)
} else if (e.key === 'Escape') {
e.preventDefault()
setRenamingChatId(null)
}
}}
onBlur={() => commitChatRename(chat.id)}
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
/>
</div>
) : (
<>
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)} className={onRenameRun ? 'pr-7' : undefined}>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
{pinnedChatIds.includes(chat.id) && (
<Pin className="size-3 shrink-0 text-muted-foreground/70 transition-opacity group-hover/menu-item:opacity-0" />
)}
</SidebarMenuButton>
{onRenameRun && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Chat options"
onClick={(e) => e.stopPropagation()}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/menu-item:opacity-100 data-[state=open]:opacity-100"
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
<DropdownMenuItem onClick={() => toggleChatPin(chat.id)}>
<Pin className="mr-2 size-3.5" />
{pinnedChatIds.includes(chat.id) ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setRenameDraft(chat.title || '')
setRenamingChatId(chat.id)
}}
>
<Pencil className="mr-2 size-3.5" />
Rename
</DropdownMenuItem>
{onDeleteRun && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteChatTarget({ id: chat.id, title: chat.title || '(Untitled chat)' })}
>
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</SidebarMenuItem>
))}
{onOpenChatHistory && (
@ -986,6 +1117,28 @@ export function SidebarContentPanel({
)}
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog open={!!deleteChatTarget} onOpenChange={(open) => { if (!open) setDeleteChatTarget(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete chat?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{deleteChatTarget?.title}&rdquo; and its full history will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => {
if (deleteChatTarget) onDeleteRun?.(deleteChatTarget.id)
setDeleteChatTarget(null)
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SidebarContent>
{/* Billing / upgrade CTA or Log in CTA */}
{isRowboatConnected && billing ? (() => {
@ -1398,7 +1551,7 @@ path: ${currentRelativePath}
if (!hasDeepgramKey) return null
const actionClass = "flex h-9 flex-1 items-center justify-center rounded-md border border-sidebar-border text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
const actionClass = "flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
const iconClass = "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
return (
@ -1432,7 +1585,7 @@ function ActionButton({ icon: Icon, label, onClick }: { icon: typeof Mic; label:
type="button"
onClick={onClick}
aria-label={label}
className="flex h-9 flex-1 items-center justify-center rounded-md border border-sidebar-border text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Icon className="size-4" />
</button>

View file

@ -1,44 +1,24 @@
import { useEffect, useState } from 'react'
import { useMemo } from 'react'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { CompactConversation } from '@/components/compact-conversation'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { turnStateToTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
import { useTurn } from '@/hooks/use-turn'
// Rendered for a spawn-agent tool call: a collapsed status card that expands
// into the child turn's live transcript. The child is a standalone turn
// (sessionId null) whose events never reach the session bus, so while it
// runs we poll sessions:getTurn via fetchAgentRunTranscript — the file is
// local and append-only, making this cheap — and do one final fetch when the
// parent tool call settles.
const POLL_MS = 1000
// (sessionId null); its durable events arrive on the turns:events spine, so
// useTurn keeps the transcript live without polling.
function useChildTranscript(
childTurnId: string | undefined,
running: boolean,
open: boolean,
): AgentRunTranscript | null {
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
useEffect(() => {
if (!childTurnId || !open) return
let alive = true
const fetchOnce = async () => {
try {
const next = await fetchAgentRunTranscript(childTurnId)
if (alive) setTranscript(next)
} catch {
// Child file may not be readable yet; the next tick retries.
}
}
void fetchOnce()
if (!running) return () => { alive = false }
const timer = setInterval(() => void fetchOnce(), POLL_MS)
return () => {
alive = false
clearInterval(timer)
}
}, [childTurnId, running, open])
return transcript
const { state } = useTurn(childTurnId, { enabled: open })
return useMemo(
() => (childTurnId && state ? turnStateToTranscript(childTurnId, state) : null),
[childTurnId, state],
)
}
// "london-weather" / "meeting_prep" → "London weather" / "Meeting prep".
@ -67,7 +47,7 @@ export function SubAgentBlock({
// header truncates with a hover tooltip carrying the full text).
const title = task ? `${name || 'Agent'}: ${task}` : name || 'Agent'
const running = item.status === 'pending' || item.status === 'running'
const transcript = useChildTranscript(item.subAgent?.childTurnId, running, open)
const transcript = useChildTranscript(item.subAgent?.childTurnId, open)
return (
<Tool open={open} onOpenChange={onOpenChange}>

View file

@ -0,0 +1,118 @@
import { useState } from 'react'
import { BarChart3, MoreHorizontal } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { TokenUsage } from '@/lib/chat-conversation'
import { formatTokenCount, totalTokensOf } from '@/lib/token-usage'
type TokenUsageMenuProps = {
usage: TokenUsage
scope: 'turn' | 'session'
modelCallCount?: number
className?: string
align?: 'start' | 'center' | 'end'
}
export function TokenUsageMenu({
usage,
scope,
modelCallCount,
className,
align = 'center',
}: TokenUsageMenuProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const total = totalTokensOf(usage)
const totalText = `${formatTokenCount(total)} tokens`
const title = `Token usage for this ${scope}`
return (
<>
{scope === 'session' ? (
// Header placement: a ghost icon button matching its siblings — hover
// explains it, click opens the stats dialog directly.
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setDialogOpen(true)}
className={cn(
'inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
className,
)}
aria-label="View token usage"
>
<BarChart3 className="size-4" strokeWidth={1.8} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">View token usage</TooltipContent>
</Tooltip>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
className,
)}
aria-label={`${title} options`}
>
<MoreHorizontal className="size-3.5" strokeWidth={1.8} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-44">
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<BarChart3 className="size-4" />
View token usage
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{totalText}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<UsageRow label="Input tokens" value={usage.inputTokens} />
<UsageRow label="Output tokens" value={usage.outputTokens} />
<UsageRow label="Cached input tokens" value={usage.cachedInputTokens} />
<UsageRow label="Reasoning tokens" value={usage.reasoningTokens} />
{modelCallCount !== undefined && modelCallCount > 0 && (
<div className="flex items-center justify-between gap-3 border-t border-border pt-2 text-sm">
<span className="text-muted-foreground">Model calls</span>
<span className="tabular-nums">{modelCallCount}</span>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
)
}
function UsageRow({ label, value }: { label: string; value: number | undefined }) {
if (!value) return null
return (
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">{formatTokenCount(value)}</span>
</div>
)
}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Plug } from 'lucide-react'
import { ArrowRight } from 'lucide-react'
import { SettingsDialog } from '@/components/settings-dialog'
import { cn } from '@/lib/utils'
@ -55,7 +55,7 @@ function ToolkitPreviewIcon({
)
}
export function ToolConnectionsCard({ className }: { className?: string }) {
export function ToolConnectionsCard({ className, compact = false }: { className?: string; compact?: boolean }) {
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
@ -101,11 +101,8 @@ export function ToolConnectionsCard({ className }: { className?: string }) {
<>
<div className={cn('rounded-xl border border-border bg-card p-4', className)}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<div className={cn('leading-snug', compact ? 'text-[12.5px]' : 'text-[13.5px]')}>
<span className="text-muted-foreground">Bring context from and take action in the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
@ -119,7 +116,10 @@ export function ToolConnectionsCard({ className }: { className?: string }) {
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
className={cn(
'ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 font-medium text-primary hover:underline',
compact ? 'text-[11.5px]' : 'text-[12px]',
)}
>
Connections
<ArrowRight className="size-3" />

View file

@ -36,7 +36,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
className
)}
{...props}

View file

@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
{...props}
/>
)

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
@ -8,7 +8,6 @@ import {
Folder as FolderIcon,
FolderOpen,
FolderPlus,
Home,
Loader2,
MessageSquare,
Pencil,
@ -365,39 +364,38 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
}, [newName, onCreateWorkspace, resetAddDialog])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex min-w-0 items-center gap-1 text-sm">
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
<div className="mx-auto flex w-full max-w-[1120px] shrink-0 items-center justify-between gap-3 pl-[22px] pr-[30px] pt-[30px] pb-4">
<div className="flex min-w-0 items-end gap-1 text-sm">
<button
type="button"
onClick={() => onNavigate(WORKSPACE_ROOT)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
'inline-flex rounded-md px-2 py-1 transition-colors',
isRoot ? 'text-[#0d0e11] dark:text-[#f4f5f7]' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<Home className="size-4" />
<span className="font-medium">Workspace</span>
<span className="text-[24px] leading-none font-[650] tracking-[-0.02em]">Workspace</span>
</button>
{breadcrumbs.map((crumb, idx) => {
const isLast = idx === breadcrumbs.length - 1
return (
<span key={crumb.path} className="flex items-center gap-1">
<ChevronRight className="size-4 text-muted-foreground/60" />
<Fragment key={crumb.path}>
<ChevronRight className="mb-[5px] size-4 shrink-0 text-muted-foreground/60" />
{isLast ? (
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
<span className="mb-[2px] rounded-md px-2 py-1 leading-none font-medium text-foreground truncate">
{crumb.name}
</span>
) : (
<button
type="button"
onClick={() => onNavigate(crumb.path)}
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
className="mb-[2px] rounded-md px-2 py-1 leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
>
{crumb.name}
</button>
)}
</span>
</Fragment>
)
})}
</div>
@ -482,12 +480,13 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
<div className="flex flex-1 overflow-hidden">
<div
className="relative flex-1 overflow-y-auto px-6 py-6"
className="relative flex-1 overflow-y-auto"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="mx-auto h-full w-full max-w-[1120px] px-[30px] py-6">
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
<FolderIcon className="size-10 opacity-50" />
@ -594,6 +593,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
})}
</div>
)}
</div>
{dropEnabled && isDraggingOver && (
<div className="pointer-events-none absolute inset-3 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5 text-primary">

View file

@ -0,0 +1,77 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { completedTurnLog } from '@/lib/session-chat/test-fixtures'
import { useAgentRunTranscript } from './use-agent-run-transcript'
// Same preload stub as use-turn.test.tsx: the hook rides useTurn (real
// turn-feed singleton) plus a runs:fetch legacy fallback.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
const handler = handlers[channel]
return handler ? handler(args) : Promise.resolve({ success: true })
},
}
beforeEach(() => {
handlers = {}
})
describe('useAgentRunTranscript', () => {
it('renders a turn-backed transcript', async () => {
handlers['sessions:getTurn'] = async () => ({
turnId: 'run-1',
events: completedTurnLog('run-1', 's1', 'do the task', 'task done'),
})
const { result } = renderHook(() => useAgentRunTranscript('run-1'))
await waitFor(() => expect(result.current.transcript).not.toBeNull())
expect(result.current.transcript?.id).toBe('run-1')
expect(result.current.transcript?.summary).toBe('task done')
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('falls back to legacy runs:fetch when the id is not a turn', async () => {
handlers['sessions:getTurn'] = async () => {
throw new Error('turn not found: legacy id')
}
handlers['runs:fetch'] = async (args) => ({
id: (args as { runId: string }).runId,
createdAt: '2026-07-01T00:00:00Z',
subUseCase: 'cron',
log: [],
})
const { result } = renderHook(() => useAgentRunTranscript('legacy-run'))
await waitFor(() => expect(result.current.transcript).not.toBeNull())
expect(result.current.transcript?.id).toBe('legacy-run')
expect(result.current.transcript?.trigger).toBe('cron')
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('surfaces an error when both the turn and legacy paths fail', async () => {
handlers['sessions:getTurn'] = async () => {
throw new Error('turn not found')
}
handlers['runs:fetch'] = async () => {
throw new Error('run not found either')
}
const { result } = renderHook(() => useAgentRunTranscript('ghost-run'))
await waitFor(() => expect(result.current.error).not.toBeNull())
expect(result.current.error).toMatch(/run not found either/)
expect(result.current.transcript).toBeNull()
expect(result.current.loading).toBe(false)
})
it('is idle without a run id', () => {
const { result } = renderHook(() => useAgentRunTranscript(null))
expect(result.current.transcript).toBeNull()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
})

View file

@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from 'react'
import {
runToTranscript,
turnStateToTranscript,
type AgentRunTranscript,
} from '@/lib/agent-transcript'
import { useTurn } from '@/hooks/use-turn'
export interface UseAgentRunTranscriptResult {
transcript: AgentRunTranscript | null
loading: boolean
error: string | null
}
// Live transcript of a headless agent run (background task, live note) by
// run id. Run ids are turn ids since the runs→turns migration, so useTurn
// keeps the transcript live via the turns:events spine; ids whose turn
// snapshot definitively fails (pre-migration legacy files) fall back to one
// runs:fetch. Callers open this for runs that already exist, so a failed
// snapshot means "not a turn", not "not created yet" — maxRetries 0 makes
// the legacy fallback immediate.
export function useAgentRunTranscript(
runId: string | undefined | null,
): UseAgentRunTranscriptResult {
const id = runId ?? undefined
const { state, error: turnError, snapshotFailed } = useTurn(id, { maxRetries: 0 })
const [legacy, setLegacy] = useState<AgentRunTranscript | null>(null)
const [legacyError, setLegacyError] = useState<string | null>(null)
useEffect(() => {
setLegacy(null)
setLegacyError(null)
if (!id || !snapshotFailed) {
return
}
let alive = true
window.ipc
.invoke('runs:fetch', { runId: id })
.then((run) => {
if (alive) setLegacy(runToTranscript(run))
})
.catch((err: unknown) => {
if (alive) setLegacyError(err instanceof Error ? err.message : String(err))
})
return () => {
alive = false
}
}, [id, snapshotFailed])
const transcript = useMemo(() => {
if (id && state) {
return turnStateToTranscript(id, state)
}
return legacy
}, [id, state, legacy])
const error = transcript ? null : (turnError ?? legacyError)
const loading = !!id && !transcript && !error
return { transcript, loading, error }
}

View file

@ -0,0 +1,147 @@
import { useCallback, useEffect, useRef, useState } from "react"
// Flavors the live model-list fetch (models:listForProvider) supports.
// "rowboat" (the signed-in gateway) is deliberately absent — its catalog
// comes from models:list, and core throws on the flavor.
export type ProviderModelsFlavor =
| "openai"
| "anthropic"
| "google"
| "openrouter"
| "aigateway"
| "ollama"
| "openai-compatible"
export type ProviderModelsStatus = "idle" | "loading" | "loaded" | "error"
export interface UseProviderModelsResult {
/** idle = credentials are insufficient to attempt a fetch. */
status: ProviderModelsStatus
models: string[]
error: string | null
/** Bypass the cache and fetch now (key-field blur / Retry). No-op while idle. */
refetch: () => void
}
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"
// The automatic fetch fires only once the credential inputs stop changing —
// never per keystroke, which would spray partial API keys at the provider.
const FETCH_DEBOUNCE_MS = 600
// Module-level so provider switches and dialog reopens don't refetch.
// Successful results only, keyed on `${flavor}|${apiKey}|${baseURL}`.
const listCache = new Map<string, string[]>()
// De-dupes concurrent requests for the same key (debounce firing + field blur).
const inFlight = new Map<string, Promise<string[]>>()
function credentialsSufficient(flavor: ProviderModelsFlavor, apiKey: string, baseURL: string): boolean {
if (flavor === "ollama" || flavor === "openai-compatible") return baseURL.length > 0
return apiKey.length > 0
}
function fetchProviderModels(
cacheKey: string,
provider: { flavor: ProviderModelsFlavor; apiKey?: string; baseURL?: string },
): Promise<string[]> {
const pending = inFlight.get(cacheKey)
if (pending) return pending
const request = window.ipc
.invoke("models:listForProvider", { provider })
.then((result) => {
if (!result.success) throw new Error(result.error || "Failed to list models")
const models = result.models ?? []
listCache.set(cacheKey, models)
return models
})
.finally(() => {
inFlight.delete(cacheKey)
})
inFlight.set(cacheKey, request)
return request
}
export function useProviderModels(input: {
flavor: ProviderModelsFlavor
apiKey: string
baseURL: string
}): UseProviderModelsResult {
const { flavor } = input
const apiKey = input.apiKey.trim()
const baseURL = input.baseURL.trim() || (flavor === "aigateway" ? AIGATEWAY_DEFAULT_BASE_URL : "")
const cacheKey = `${flavor}|${apiKey}|${baseURL}`
const sufficient = credentialsSufficient(flavor, apiKey, baseURL)
const [state, setState] = useState<{
key: string
status: ProviderModelsStatus
models: string[]
error: string | null
}>({ key: "", status: "idle", models: [], error: null })
// Bumped whenever the inputs change (and on unmount) so completions of
// superseded fetches never write state.
const epochRef = useRef(0)
const startFetch = useCallback(() => {
const epoch = ++epochRef.current
setState({ key: cacheKey, status: "loading", models: [], error: null })
fetchProviderModels(cacheKey, {
flavor,
apiKey: apiKey || undefined,
baseURL: baseURL || undefined,
})
.then((models) => {
if (epochRef.current !== epoch) return
setState({ key: cacheKey, status: "loaded", models, error: null })
})
.catch((err: unknown) => {
if (epochRef.current !== epoch) return
const message = err instanceof Error ? err.message : "Failed to list models"
setState({ key: cacheKey, status: "error", models: [], error: message })
})
}, [cacheKey, flavor, apiKey, baseURL])
useEffect(() => {
epochRef.current++
if (!sufficient) {
setState({ key: cacheKey, status: "idle", models: [], error: null })
return
}
const cached = listCache.get(cacheKey)
if (cached) {
setState({ key: cacheKey, status: "loaded", models: cached, error: null })
return
}
setState({ key: cacheKey, status: "loading", models: [], error: null })
const timer = setTimeout(() => {
// A blur-triggered refetch may have already filled the cache while the
// debounce was pending — don't fetch the same key twice.
const nowCached = listCache.get(cacheKey)
if (nowCached) {
setState({ key: cacheKey, status: "loaded", models: nowCached, error: null })
return
}
startFetch()
}, FETCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [cacheKey, sufficient, startFetch])
useEffect(() => () => {
epochRef.current++
}, [])
const refetch = useCallback(() => {
if (!sufficient) return
listCache.delete(cacheKey)
startFetch()
}, [sufficient, cacheKey, startFetch])
// State lags the inputs by one render (the effect above reconciles), so
// derive the answer for the *current* inputs — a provider switch must never
// flash the previous provider's list.
if (state.key !== cacheKey) {
const cached = sufficient ? listCache.get(cacheKey) : undefined
if (cached) return { status: "loaded", models: cached, error: null, refetch }
return { status: sufficient ? "loading" : "idle", models: [], error: null, refetch }
}
return { status: state.status, models: state.models, error: state.error, refetch }
}

View file

@ -0,0 +1,148 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TurnBusEvent } from '@x/shared/src/turns.js'
import {
assistantText,
completed,
completedTurnLog,
created,
requested,
turnCompleted,
user,
type TEvent,
} from '@/lib/session-chat/test-fixtures'
import { useTurn } from './use-turn'
// The hook wires the real turn-feed singleton and window.ipc, so the tests
// stub the preload surface: `on` captures the feed listener (the feed
// attaches lazily on first subscribe), `invoke` routes by channel through a
// per-test handler map.
let feedListener: ((event: TurnBusEvent) => void) | null = null
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
;(window as unknown as { ipc: unknown }).ipc = {
on: (channel: string, handler: (event: TurnBusEvent) => void) => {
if (channel === 'turns:events') feedListener = handler
return () => undefined
},
invoke: (channel: string, args: unknown) => {
const handler = handlers[channel]
return handler ? handler(args) : Promise.resolve({ success: true })
},
}
function emit(event: TurnBusEvent): void {
act(() => feedListener?.(event))
}
function durable(turnId: string, event: TEvent, offset: number): TurnBusEvent {
return { turnId, sessionId: null, event, offset }
}
function serveTurns(turns: Record<string, TEvent[]>): void {
handlers['sessions:getTurn'] = async (args) => {
const { turnId } = args as { turnId: string }
const events = turns[turnId]
if (!events) throw new Error(`turn not found: ${turnId}`)
return { turnId, events: [...events] }
}
}
beforeEach(() => {
handlers = {}
})
describe('useTurn', () => {
it('fetches the snapshot and applies contiguous feed events', async () => {
const T = 'turn-live'
const full: TEvent[] = [
created(T, 's1', user('go')),
requested(T, 0),
completed(T, 0, assistantText('done')),
turnCompleted(T, 'done'),
]
serveTurns({ [T]: full.slice(0, 2) })
const { result } = renderHook(() => useTurn(T))
await waitFor(() => expect(result.current.state).not.toBeNull())
expect(result.current.state?.terminal).toBeUndefined()
emit(durable(T, full[2], 3))
emit(durable(T, full[3], 4))
expect(result.current.state?.terminal?.type).toBe('turn_completed')
expect(result.current.error).toBeNull()
})
it('resets state when turnId changes instead of showing the stale turn', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
serveTurns({ 'turn-a': completedTurnLog('turn-a', 's1', 'qa', 'aa') })
const base = handlers['sessions:getTurn']
handlers['sessions:getTurn'] = async (args) => {
if ((args as { turnId: string }).turnId === 'turn-b') {
await gate
return { turnId: 'turn-b', events: completedTurnLog('turn-b', 's1', 'qb', 'ab') }
}
return base(args)
}
const { result, rerender } = renderHook(({ id }) => useTurn(id), {
initialProps: { id: 'turn-a' },
})
await waitFor(() => expect(result.current.state).not.toBeNull())
rerender({ id: 'turn-b' })
// turn-b's snapshot is gated: the hook must show nothing, not turn-a.
expect(result.current.state).toBeNull()
release()
await waitFor(() =>
expect(result.current.state?.definition.turnId).toBe('turn-b'),
)
})
it('keeps the last state while disabled and refetches on re-enable', async () => {
const getTurn = vi.fn(async () => ({
turnId: 'turn-a',
events: completedTurnLog('turn-a', 's1', 'q', 'a'),
}))
handlers['sessions:getTurn'] = getTurn
const { result, rerender } = renderHook(
({ enabled }) => useTurn('turn-a', { enabled }),
{ initialProps: { enabled: true } },
)
await waitFor(() => expect(result.current.state).not.toBeNull())
const fetches = getTurn.mock.calls.length
rerender({ enabled: false })
expect(result.current.state).not.toBeNull() // kept while hidden
rerender({ enabled: true })
await waitFor(() =>
expect(getTurn.mock.calls.length).toBeGreaterThan(fetches),
)
expect(result.current.state).not.toBeNull()
})
it('reports snapshotFailed after retries and recovers via a feed event', async () => {
const T = 'turn-late'
const full = completedTurnLog(T, 's1', 'q', 'a')
let available = false
handlers['sessions:getTurn'] = async () => {
if (!available) throw new Error('not created yet')
return { turnId: T, events: [...full] }
}
const { result } = renderHook(() => useTurn(T, { maxRetries: 0 }))
await waitFor(() => expect(result.current.snapshotFailed).toBe(true))
expect(result.current.state).toBeNull()
available = true
emit(durable(T, full[full.length - 1], full.length))
await waitFor(() => expect(result.current.state).not.toBeNull())
expect(result.current.snapshotFailed).toBe(false)
})
})

View file

@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react'
import type { TurnState } from '@x/shared/src/turns.js'
import { subscribeTurnFeed } from '@/lib/turn-feed'
import { followTurn } from '@/lib/turn-follower'
export interface UseTurnResult {
state: TurnState | null
error: string | null
// Snapshot retries exhausted with no state — the id may not be a turn at
// all (e.g. a pre-migration legacy run id). Clears if a later feed event
// recovers the turn.
snapshotFailed: boolean
}
// Live view of one turn by id: snapshot via sessions:getTurn, then durable
// events from the turns:events spine (see lib/turn-follower.ts for the join
// protocol). Works for any turn — session chat, headless runners, spawned
// sub-agents.
export function useTurn(
turnId: string | undefined,
opts?: { enabled?: boolean; maxRetries?: number },
): UseTurnResult {
const enabled = opts?.enabled ?? true
const maxRetries = opts?.maxRetries
const [state, setState] = useState<TurnState | null>(null)
const [error, setError] = useState<string | null>(null)
const [snapshotFailed, setSnapshotFailed] = useState(false)
const lastTurnId = useRef<string | undefined>(undefined)
useEffect(() => {
// A different turn means the previous turn's state is stale; a mere
// enabled toggle keeps the last rendered state while hidden.
if (turnId !== lastTurnId.current) {
lastTurnId.current = turnId
setState(null)
setError(null)
setSnapshotFailed(false)
}
if (!turnId || !enabled) {
return
}
return followTurn(turnId, {
fetchTurn: (id) => window.ipc.invoke('sessions:getTurn', { turnId: id }),
subscribe: subscribeTurnFeed,
onState: (next) => {
setState(next)
setError(null)
setSnapshotFailed(false)
},
onError: (message) => setError(message),
onSnapshotFailed: () => setSnapshotFailed(true),
...(maxRetries === undefined ? {} : { maxRetries }),
})
}, [turnId, enabled, maxRetries])
return { state, error, snapshotFailed }
}

View file

@ -493,7 +493,16 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
const filename = calendarEvent?.summary
? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim()
: `meeting-${timestamp}`;
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
let notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
// Title-derived names collide within a day — every ad-hoc detection is
// titled "Meeting", and recurring calendar events repeat their summary.
// Never overwrite an earlier meeting's note: suffix with the timestamp.
if (calendarEvent?.summary) {
try {
const { exists } = await window.ipc.invoke('workspace:exists', { path: notePath });
if (exists) notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}-${timestamp}.md`;
} catch { /* fall through with the unsuffixed path */ }
}
notePathRef.current = notePath;
calendarEventRef.current = calendarEvent;

View file

@ -1,9 +1,8 @@
import { StrictMode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
import type { TurnBusEvent } from '@x/shared/src/turns.js'
import type { SessionsClient } from '@/lib/session-chat/client'
import type { SessionFeedListener } from '@/lib/session-chat/feed'
import {
assistantText,
completed,
@ -21,8 +20,9 @@ const S1 = 'sess-1'
function makeDeps() {
const calls: Array<{ method: string; args: unknown[] }> = []
let emit: SessionFeedListener = () => undefined
let emit: (event: TurnBusEvent) => void = () => undefined
let unsubscribed = 0
const deltaSubs: string[] = []
const sessions = new Map([[S1, sessionState(S1, ['turn-1'])]])
const turns = new Map([['turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1')]])
const client: SessionsClient = {
@ -54,22 +54,46 @@ function makeDeps() {
return {
deps: {
client,
subscribeFeed: (listener: SessionFeedListener) => {
subscribeTurnFeed: (listener: (event: TurnBusEvent) => void) => {
emit = listener
return () => {
unsubscribed += 1
}
},
subscribeDeltas: (turnId: string) => {
deltaSubs.push(turnId)
return () => {
const i = deltaSubs.indexOf(turnId)
if (i >= 0) deltaSubs.splice(i, 1)
}
},
},
calls,
emit: (event: SessionBusEvent) => emit(event),
emit: (event: TurnBusEvent) => emit(event),
deltaSubs,
getUnsubscribed: () => unsubscribed,
}
}
function durable(
turnId: string,
event: TurnBusEvent['event'],
offset: number,
): TurnBusEvent {
return { turnId, sessionId: S1, event, offset }
}
function delta(turnId: string, text: string): TurnBusEvent {
return {
turnId,
sessionId: S1,
event: { type: 'text_delta', turnId, modelCallIndex: 0, delta: text },
}
}
describe('useSessionChat', () => {
it('seeds from the session, follows live events, and routes actions', async () => {
const { deps, calls, emit } = makeDeps()
const { deps, calls, emit, deltaSubs } = makeDeps()
const { result } = renderHook(() => useSessionChat(S1, deps), { wrapper: StrictMode })
await waitFor(() => {
@ -78,25 +102,23 @@ describe('useSessionChat', () => {
expect(
result.current.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['q1', 'a1'])
// The window subscribed to the latest turn's deltas.
expect(deltaSubs).toEqual(['turn-1'])
// A new turn streams in over the feed.
// A new turn streams in over the turns:events spine.
act(() => {
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
emit({
kind: 'turn-event',
sessionId: S1,
turnId: 'turn-2',
event: { type: 'text_delta', turnId: 'turn-2', modelCallIndex: 0, delta: 'a2…' },
})
emit(durable('turn-2', created('turn-2', S1, user('q2')), 1))
emit(durable('turn-2', requested('turn-2', 0), 2))
emit(delta('turn-2', 'a2…'))
})
expect(result.current.latestTurnId).toBe('turn-2')
expect(result.current.chatState?.currentAssistantMessage).toBe('a2…')
expect(result.current.chatState?.isProcessing).toBe(true)
expect(deltaSubs).toEqual(['turn-2'])
act(() => {
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: completed('turn-2', 0, assistantText('a2')) })
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: turnCompleted('turn-2', 'a2') })
emit(durable('turn-2', completed('turn-2', 0, assistantText('a2')), 3))
emit(durable('turn-2', turnCompleted('turn-2', 'a2'), 4))
})
expect(result.current.chatState?.isProcessing).toBe(false)
@ -111,13 +133,14 @@ describe('useSessionChat', () => {
})
it('unsubscribes from the feed on unmount (StrictMode double-mounts included)', async () => {
const { deps, getUnsubscribed } = makeDeps()
const { deps, getUnsubscribed, deltaSubs } = makeDeps()
const { unmount } = renderHook(() => useSessionChat(S1, deps), {
wrapper: StrictMode,
})
unmount()
// StrictMode's simulated cleanup plus the real unmount: every subscribe
// was matched by an unsubscribe.
// was matched by an unsubscribe, and no delta subscription leaks.
expect(getUnsubscribed()).toBe(2)
expect(deltaSubs).toEqual([])
})
})

View file

@ -1,11 +1,22 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { ipcSessionsClient } from '@/lib/session-chat/client'
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
import { subscribeTurnFeed } from '@/lib/turn-feed'
import { SessionChatStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
// Declare "this window is watching turn X" so main forwards its deltas.
// Fire-and-forget on both edges: a lost subscribe only degrades streaming
// granularity (durable events still arrive), never correctness.
function subscribeDeltas(turnId: string): () => void {
void window.ipc.invoke('turns:subscribe', { turnId }).catch(() => undefined)
return () => {
void window.ipc.invoke('turns:unsubscribe', { turnId }).catch(() => undefined)
}
}
const defaultDeps: SessionChatStoreDeps = {
client: ipcSessionsClient,
subscribeFeed: subscribeSessionFeed,
subscribeTurnFeed,
subscribeDeltas,
}
// Thin subscription over SessionChatStore — all logic (seeding, feed events,

View file

@ -1,16 +1,16 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { ipcSessionsClient } from '@/lib/session-chat/client'
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
import { SessionListStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
import { SessionListStore, type SessionListStoreDeps } from '@/lib/session-chat/store'
const defaultDeps: SessionChatStoreDeps = {
const defaultDeps: SessionListStoreDeps = {
client: ipcSessionsClient,
subscribeFeed: subscribeSessionFeed,
}
// The session list (chat history sidebar): seeded from sessions:list, kept
// current by index-changed feed events. Logic lives in SessionListStore.
export function useSessions(deps: SessionChatStoreDeps = defaultDeps) {
export function useSessions(deps: SessionListStoreDeps = defaultDeps) {
const [store] = useState(() => new SessionListStore(deps))
useEffect(() => {
const disconnect = store.connect()

View file

@ -1,6 +1,6 @@
import type { z } from 'zod'
import type { Run } from '@x/shared/src/runs.js'
import { reduceTurn, type TurnEvent } from '@x/shared/src/turns.js'
import { reduceTurn, type TurnEvent, type TurnState } from '@x/shared/src/turns.js'
import type { ConversationItem } from '@/lib/chat-conversation'
import { runLogToConversation } from '@/lib/run-to-conversation'
import { buildTurnConversation } from '@/lib/session-chat/turn-view'
@ -24,7 +24,15 @@ export function turnToTranscript(
turnId: string,
events: Array<z.infer<typeof TurnEvent>>,
): AgentRunTranscript {
const state = reduceTurn(events)
return turnStateToTranscript(turnId, reduceTurn(events))
}
// Reduced-state variant for consumers that already hold a live TurnState
// (useTurn) and should not re-reduce the event log.
export function turnStateToTranscript(
turnId: string,
state: TurnState,
): AgentRunTranscript {
const out: AgentRunTranscript = {
id: turnId,
createdAt: state.definition.ts,

View file

@ -0,0 +1,39 @@
// One shared consumer per broadcast IPC channel; stores and hooks tap this
// fan-out instead of each opening their own IPC listener. Factory so tests
// can drive a fake source.
export type FeedListener<T> = (event: T) => void
export type FeedSource<T> = (listener: FeedListener<T>) => () => void
export interface BroadcastFeed<T> {
subscribe(listener: FeedListener<T>): () => void
}
export function createBroadcastFeed<T>(source: FeedSource<T>): BroadcastFeed<T> {
const listeners = new Set<FeedListener<T>>()
let detach: (() => void) | null = null
const ensureStarted = () => {
if (detach) return
detach = source((event) => {
// Copy so (un)subscribing during dispatch is safe.
for (const listener of [...listeners]) {
try {
listener(event)
} catch {
// A misbehaving subscriber must never break the feed.
}
}
})
}
return {
subscribe(listener: FeedListener<T>): () => void {
ensureStarted()
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}

View file

@ -4,6 +4,14 @@ import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRe
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
export interface TokenUsage {
inputTokens?: number
outputTokens?: number
totalTokens?: number
reasoningTokens?: number
cachedInputTokens?: number
}
export interface MessageAttachment {
path: string
filename: string
@ -45,13 +53,33 @@ export interface ErrorMessage {
timestamp: number
}
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
// User-facing names for the canonical effort ladder ("auto" = absent).
export const REASONING_EFFORT_LABELS: Record<ReasoningEffortLevel, string> = {
low: 'Fast',
medium: 'Balanced',
high: 'Thorough',
}
export interface TurnUsageMessage {
id: string
kind: 'turn-usage'
usage: TokenUsage
modelCallCount: number
// The turn's reasoning effort (from turn_created.config); absent = auto.
reasoningEffort?: ReasoningEffortLevel
timestamp: number
}
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage | TurnUsageMessage
export type PermissionResponse = 'approve' | 'deny'
export type ChatTabViewState = {
runId: string | null
conversation: ConversationItem[]
currentAssistantMessage: string
sessionUsage: TokenUsage
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
@ -67,6 +95,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
runId: null,
conversation: [],
currentAssistantMessage: '',
sessionUsage: {},
pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(),
permissionResponses: new Map(),
@ -79,6 +108,8 @@ export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'r
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
'kind' in item && item.kind === 'error'
export const isTurnUsageMessage = (item: ConversationItem): item is TurnUsageMessage =>
'kind' in item && item.kind === 'turn-usage'
export const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {

View file

@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { prepareEmailHtml, QUOTED_CLASS, splitPlainTextQuote, stripQuotedReplyText } from './email-quotes'
/** Visible text once the stylesheet hides the tagged nodes. */
function visible(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/\s+/g, ' ').trim()
}
// The divider Outlook desktop emits: the header lives inside it, the quoted
// chain trails it as siblings.
const OUTLOOK_QUOTE = `
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0in 0in 0in">
<p class="MsoNormal"><b>From:</b> Counsel &lt;c@firm.com&gt;<br>
<b>Sent:</b> Tuesday, July 7, 2026 9:14 AM<br>
<b>To:</b> Me &lt;me@co.com&gt;<br>
<b>Subject:</b> Re: Series A docs</p>
</div>
<p class="MsoNormal">The indemnity clause needs revisiting.</p>`
describe('prepareEmailHtml — quote detection', () => {
it('hides an Outlook quoted chain, including the siblings after the divider', () => {
const { html, hasQuote } = prepareEmailHtml(
`<p class="MsoNormal">Agreed, see below.</p>${OUTLOOK_QUOTE}`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toBe('Agreed, see below.')
})
it('hides an Outlook quote nested beside a signature in an outer wrapper', () => {
const { html, hasQuote } = prepareEmailHtml(
`<div><p>Sounds good.</p><p>-- Jane, Partner</p><div>${OUTLOOK_QUOTE}</div></div>`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toContain('Sounds good.')
expect(visible(html)).toContain('-- Jane, Partner')
expect(visible(html)).not.toContain('indemnity')
})
it('hides Outlook-on-the-web quoted chains via divRplyFwdMsg', () => {
const { html, hasQuote } = prepareEmailHtml(
`<div>Short reply.</div><div id="divRplyFwdMsg"><b>From:</b> C</div><div>Older content.</div>`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toBe('Short reply.')
})
it('still hides Gmail wrapper quotes', () => {
const { html, hasQuote } = prepareEmailHtml(
`<div>Thanks!</div><div class="gmail_quote"><div class="gmail_attr">On Tue, X wrote:</div><blockquote>old</blockquote></div>`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toBe('Thanks!')
})
it('leaves a border-top divider alone when it is a signature rule, not a quote header', () => {
const html = `<p>Hi there.</p><div style="border-top:solid #E1E1E1 1.0pt"><p>Jane Doe | Acme | jane@acme.com</p></div>`
const prepared = prepareEmailHtml(html)
expect(prepared.hasQuote).toBe(false)
expect(visible(prepared.html)).toContain('Jane Doe')
})
it('shows a forward in full rather than collapsing the whole body', () => {
// Everything is quoted, so hiding would leave a blank message.
const prepared = prepareEmailHtml(
`<div class="gmail_quote"><div class="gmail_attr">---------- Forwarded message ---------</div><div>The actual content.</div></div>`,
)
expect(prepared.hasQuote).toBe(false)
expect(visible(prepared.html)).toContain('The actual content.')
})
it('reports no quote for a plain message', () => {
expect(prepareEmailHtml('<p>Just a note.</p>').hasQuote).toBe(false)
})
it('keeps a <style> block the parser would otherwise hoist into <head>', () => {
const { html } = prepareEmailHtml('<style>.a{color:red}</style><p>Body</p>')
expect(html).toContain('.a{color:red}')
})
it('preserves <body> attributes, which carry the email background', () => {
// Embedded in the host <body>, the parser merges these onto it. Dropping
// the tag would strip the background off every styled newsletter.
const { html } = prepareEmailHtml('<html><body bgcolor="#f4f4f4" style="margin:0"><p>Hi</p></body></html>')
expect(html).toContain('bgcolor="#f4f4f4"')
expect(html).toContain('margin:0')
})
it('ignores quoted content when deciding whether the email is styled', () => {
// The table lives only in the quote, so the body should still adapt to theme.
const { styled } = prepareEmailHtml(
`<p>ok</p><div class="gmail_quote"><table><tr><td>old</td></tr></table></div>`,
)
expect(styled).toBe(false)
})
})
describe('splitPlainTextQuote', () => {
it('splits on an Outlook From:/Sent:/To:/Subject: header block', () => {
const body = [
'Agreed.',
'',
'From: Counsel <c@firm.com>',
'Sent: Tuesday, July 7, 2026 9:14 AM',
'To: Me <me@co.com>',
'Subject: Re: Series A docs',
'',
'The indemnity clause needs revisiting.',
].join('\n')
const { visible: head, quoted } = splitPlainTextQuote(body)
expect(head).toBe('Agreed.')
expect(quoted).toContain('The indemnity clause')
})
it('splits on "On … wrote:"', () => {
const { visible: head, quoted } = splitPlainTextQuote('Thanks!\n\nOn Tue, Jul 7, X wrote:\n> old')
expect(head).toBe('Thanks!')
expect(quoted).toContain('> old')
})
it('splits on a forwarded-message separator', () => {
const { visible: head, quoted } = splitPlainTextQuote('FYI\n\n---------- Forwarded message ---------\nbody')
expect(head).toBe('FYI')
expect(quoted).toContain('body')
})
it('returns no quote when there is no boundary', () => {
expect(splitPlainTextQuote('Just a note.').quoted).toBeNull()
})
it('does not treat a bare "From:" line as a boundary', () => {
expect(splitPlainTextQuote('From: the desk of Jane\n\nHello.').quoted).toBeNull()
})
})
describe('stripQuotedReplyText', () => {
it('drops an Outlook quoted tail', () => {
const body = 'Agreed.\n\nFrom: C <c@f.com>\nSent: Tue\nTo: Me\nSubject: Re: x\n\nold'
expect(stripQuotedReplyText(body)).toBe('Agreed.')
})
it('drops a ">" quoted tail', () => {
expect(stripQuotedReplyText('Reply.\n\n> old line')).toBe('Reply.')
})
})

View file

@ -0,0 +1,200 @@
/**
* Quoted-reply detection for rendered email bodies.
*
* Quoted chains come in two shapes, and the difference decides how they hide:
*
* - Wrapper quotes (Gmail, Apple Mail, Yahoo, Proton): a single element
* contains the whole quoted chain, so hiding that element is enough.
* - Boundary quotes (Outlook, Thunderbird): a divider element holds only the
* "From:/Sent:/Subject:" header and the quoted chain trails it as siblings,
* so everything from the divider onward has to be hidden.
*
* Treating the second kind as the first is why Outlook threads used to render
* their entire history inline with no toggle to collapse it.
*/
/** Marks nodes the iframe stylesheet hides until quotes are toggled on. */
export const QUOTED_CLASS = 'rb-quoted'
const WRAPPER_QUOTE_SELECTOR = [
'.gmail_quote',
'.gmail_attr',
'blockquote[type="cite"]',
'.yahoo_quoted',
'.protonmail_quote',
].join(', ')
const BOUNDARY_QUOTE_SELECTOR = [
'#divRplyFwdMsg', // Outlook on the web / "new Outlook"
'#appendonsend', // Outlook, an empty marker sitting before the quoted chain
'.moz-cite-prefix', // Thunderbird
].join(', ')
// Outlook desktop draws the divider as an inline-styled <div>. The colour is
// the only stable part of the rule — padding units vary (0in vs 0cm), casing
// varies (#E1E1E1 vs #e1e1e1), and `border:none` may or may not precede it.
const OUTLOOK_DIVIDER_STYLE = /border-top\s*:\s*solid\s*#?e1e1e1/i
const HEADER_SCAN_CHARS = 800
function hasQuotedHeaderBlock(text: string): boolean {
const head = text.slice(0, HEADER_SCAN_CHARS)
return /\bFrom:/i.test(head) && /\b(Sent|Date):/i.test(head) && /\bSubject:/i.test(head)
}
// A bare border-top div is also how plenty of signatures and marketing footers
// draw a horizontal rule, so require the header block before calling it a
// quote boundary. Wrongly hiding someone's signature is worse than wrongly
// showing a quote.
function isOutlookDivider(el: Element): boolean {
if (!OUTLOOK_DIVIDER_STYLE.test(el.getAttribute('style') || '')) return false
return hasQuotedHeaderBlock(el.textContent || '')
}
// Hide the divider plus everything after it. The quoted chain can sit outside
// the divider's parent, so climb to the body marking later siblings at each
// level. Quoted history always terminates the message, so nothing below the
// boundary is content worth keeping.
function markFromBoundary(el: Element, body: Element): void {
el.classList.add(QUOTED_CLASS)
let node: Element | null = el
while (node && node !== body) {
for (let sib = node.nextElementSibling; sib; sib = sib.nextElementSibling) {
sib.classList.add(QUOTED_CLASS)
}
node = node.parentElement
}
}
/** Tags quoted nodes with {@link QUOTED_CLASS}. Returns true if any were found. */
export function markQuotedNodes(doc: Document): boolean {
const body = doc.body
if (!body) return false
let found = false
for (const el of Array.from(body.querySelectorAll(WRAPPER_QUOTE_SELECTOR))) {
el.classList.add(QUOTED_CLASS)
found = true
}
const boundaries = Array.from(body.querySelectorAll(BOUNDARY_QUOTE_SELECTOR))
for (const el of Array.from(body.querySelectorAll('div[style]'))) {
if (isOutlookDivider(el)) boundaries.push(el)
}
for (const el of boundaries) {
// Already inside a quote we hid, or reached by an earlier boundary's sweep.
if (el.closest(`.${QUOTED_CLASS}`)) continue
markFromBoundary(el, body)
found = true
}
return found
}
// True if the HTML — ignoring quoted 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) gets an iframe that adapts to the app theme; styled
// HTML keeps the white "paper" look so newsletters render as sent.
function isStyledDocument(doc: Document): boolean {
const clone = doc.cloneNode(true) as Document
clone.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.remove())
if (clone.querySelector('table')) return true
for (const img of Array.from(clone.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 = clone.body?.innerHTML || ''
if (/bgcolor\s*=/i.test(visible)) return true
if (/background-(color|image)\s*:/i.test(visible)) return true
return false
}
export interface PreparedEmail {
/** Body HTML with quoted nodes tagged, ready to embed in the iframe. */
html: string
hasQuote: boolean
styled: boolean
}
// A forward is quoted content all the way down: hiding it leaves a blank body
// behind a "•••" nobody knows to press. When nothing survives, show everything
// and drop the toggle. Real replies clear this easily — the shortest in a
// 558-message sample left 13 visible characters, forwards left zero.
function unmarkIfNothingRemains(doc: Document): boolean {
const clone = doc.cloneNode(true) as Document
clone.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.remove())
const visible = (clone.body?.textContent || '').replace(/[\s\u00a0]+/g, '')
if (visible.length > 0) return true
doc.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.classList.remove(QUOTED_CLASS))
return false
}
/**
* Parse once, tag the quotes, and decide the styling so the iframe hides
* quoted history on its first paint and reports the right height immediately.
*/
export function prepareEmailHtml(rawHtml: string): PreparedEmail {
const doc = new DOMParser().parseFromString(rawHtml, 'text/html')
const hasQuote = markQuotedNodes(doc) && unmarkIfNothingRemains(doc)
const styled = isStyledDocument(doc)
// Serialize <head> and <body> as *elements*, not just their contents. Most
// emails carry a <body bgcolor=… style=…>, and when this HTML is embedded
// inside the host <body>, the parser merges those attributes onto it — which
// is how backgrounds have always applied. Concatenating innerHTML would drop
// the tag and with it the attributes.
const html = doc.documentElement?.innerHTML || doc.body?.innerHTML || ''
return { html, hasQuote, styled }
}
export 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 quoteBoundaryIndex(lines: string[]): number {
return 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('>'))
})
}
function normalizeNewlines(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
/**
* Split a plain-text body into the new content and the quoted tail. Recognises
* every boundary {@link isReplyQuoteBoundary} does, not just "On … wrote:".
*/
export function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
const lines = normalizeNewlines(text).split('\n')
const boundary = quoteBoundaryIndex(lines)
if (boundary < 0) return { visible: text, quoted: null }
const quoted = lines.slice(boundary).join('\n')
if (!quoted.trim()) return { visible: text, quoted: null }
return { visible: lines.slice(0, boundary).join('\n').trimEnd(), quoted }
}
/** Drop the quoted tail entirely — used when seeding the composer from a draft. */
export function stripQuotedReplyText(text: string): string {
const lines = normalizeNewlines(text).split('\n')
const boundary = quoteBoundaryIndex(lines)
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()
}

View file

@ -1,38 +1,18 @@
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
export type SessionFeedListener = (event: SessionBusEvent) => void
export type SessionFeedSource = (listener: SessionFeedListener) => () => void
import {
createBroadcastFeed,
type FeedListener,
type FeedSource,
} from '@/lib/broadcast-feed'
// One shared consumer of the sessions:events push channel; stores tap this
// fan-out instead of each opening their own IPC listener. Factory so tests
// can drive a fake source.
// fan-out instead of each opening their own IPC listener.
export type SessionFeedListener = FeedListener<SessionBusEvent>
export type SessionFeedSource = FeedSource<SessionBusEvent>
export function createSessionFeed(source: SessionFeedSource) {
const listeners = new Set<SessionFeedListener>()
let detach: (() => void) | null = null
const ensureStarted = () => {
if (detach) return
detach = source((event) => {
// Copy so (un)subscribing during dispatch is safe.
for (const listener of [...listeners]) {
try {
listener(event)
} catch {
// A misbehaving subscriber must never break the feed.
}
}
})
}
return {
subscribe(listener: SessionFeedListener): () => void {
ensureStarted()
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
return createBroadcastFeed<SessionBusEvent>(source)
}
const appFeed = createSessionFeed((listener) => window.ipc.on('sessions:events', listener))

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SessionBusEvent, SessionState } from '@x/shared/src/sessions.js'
import type { SessionState } from '@x/shared/src/sessions.js'
import { isDurableTurnEvent, type TurnBusEvent } from '@x/shared/src/turns.js'
import { isChatMessage } from '@/lib/chat-conversation'
import type { SessionsClient } from './client'
import type { SessionFeedListener } from './feed'
@ -86,12 +87,16 @@ class FakeClient implements SessionsClient {
function makeStore() {
const client = new FakeClient()
let emit: SessionFeedListener = () => undefined
let emit: (event: TurnBusEvent) => void = () => undefined
let subscribed = 0
let unsubscribed = 0
// Live delta subscriptions by turn id (multiset semantics not needed: the
// store holds at most one).
const deltaSubs: string[] = []
offsetByTurn.clear()
const store = new SessionChatStore({
client,
subscribeFeed: (listener) => {
subscribeTurnFeed: (listener) => {
subscribed += 1
emit = listener
return () => {
@ -99,26 +104,44 @@ function makeStore() {
emit = () => undefined
}
},
subscribeDeltas: (turnId) => {
deltaSubs.push(turnId)
return () => {
const i = deltaSubs.indexOf(turnId)
if (i >= 0) deltaSubs.splice(i, 1)
}
},
})
const disconnect = store.connect()
return {
client,
store,
disconnect,
emit: (event: SessionBusEvent) => emit(event),
emit: (event: TurnBusEvent) => emit(event),
deltaSubs,
getSubscribed: () => subscribed,
getUnsubscribed: () => unsubscribed,
}
}
// Tags an event for the turn feed; durable events get their 1-based per-turn
// offset assigned automatically (mirroring the file line index the runtime
// stamps on the bus envelope).
const offsetByTurn = new Map<string, number>()
function turnEvent(
sessionId: string,
turnId: string,
event:
| TEvent
| { type: 'text_delta' | 'reasoning_delta'; turnId: string; modelCallIndex: number; delta: string },
): SessionBusEvent {
return { kind: 'turn-event', sessionId, turnId, event }
): TurnBusEvent {
if (!isDurableTurnEvent(event)) {
return { turnId, sessionId, event }
}
const offset = (offsetByTurn.get(turnId) ?? 0) + 1
offsetByTurn.set(turnId, offset)
return { turnId, sessionId, event, offset }
}
describe('SessionChatStore', () => {
@ -203,6 +226,35 @@ describe('SessionChatStore', () => {
).toEqual(['q1', 'a1', 'q2'])
})
it('keeps exactly one delta subscription, following the latest turn', async () => {
const { client, store, emit, deltaSubs, disconnect } = makeStore()
client.sessions.set(S1, sessionState(S1, ['turn-1']))
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1'))
await store.setSession(S1)
expect(deltaSubs).toEqual(['turn-1'])
emit(turnEvent(S1, 'turn-2', created('turn-2', S1, user('q2'))))
expect(deltaSubs).toEqual(['turn-2'])
disconnect()
expect(deltaSubs).toEqual([])
})
it('drops duplicate feed events already covered by the snapshot', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, []))
await store.setSession(S1)
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
// A replay of line 2 (e.g. snapshot/live race) must not double-apply —
// the reducer would throw on the impossible history.
emit({ turnId: 'turn-1', sessionId: S1, event: requested('turn-1', 0), offset: 2 })
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
expect(store.getSnapshot().error).toBeNull()
expect(store.getSnapshot().chatState?.isProcessing).toBe(false)
})
it('ignores events for other sessions', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, []))

View file

@ -2,11 +2,12 @@ import type { z } from 'zod'
import type { UserMessage } from '@x/shared/src/message.js'
import type { SessionBusEvent, SessionIndexEntry } from '@x/shared/src/sessions.js'
import {
isDurableTurnEvent,
reduceTurn,
type JsonValue,
type TurnBusEvent,
type TurnEvent,
type TurnState,
type TurnStreamEvent,
} from '@x/shared/src/turns.js'
import type { SendMessageConfig, SessionsClient } from './client'
import type { SessionFeedListener } from './feed'
@ -30,7 +31,12 @@ export interface SessionChatSnapshot {
export interface SessionChatStoreDeps {
client: SessionsClient
subscribeFeed: (listener: SessionFeedListener) => () => void
// The turns:events spine (durable events with offsets, plus deltas for
// turns this window subscribed to).
subscribeTurnFeed: (listener: (event: TurnBusEvent) => void) => () => void
// Declares "this window is watching turn X" so main forwards its deltas;
// returns the unsubscribe.
subscribeDeltas: (turnId: string) => () => void
}
// Framework-agnostic controller for one active session's chat. Owns all the
@ -39,7 +45,8 @@ export interface SessionChatStoreDeps {
// hook is a thin useSyncExternalStore subscription over it.
export class SessionChatStore {
private readonly client: SessionsClient
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
private readonly subscribeTurnFeed: (listener: (event: TurnBusEvent) => void) => () => void
private readonly subscribeDeltas: (turnId: string) => () => void
private feedDisconnect: (() => void) | null = null
private readonly listeners = new Set<() => void>()
@ -53,6 +60,9 @@ export class SessionChatStore {
private error: string | null = null
// Guards stale async loads after a session switch.
private generation = 0
// The turn whose deltas this window currently receives.
private deltaTurnId: string | null = null
private deltaUnsub: (() => void) | null = null
private snapshot: SessionChatSnapshot = {
sessionId: null,
@ -64,7 +74,8 @@ export class SessionChatStore {
constructor(deps: SessionChatStoreDeps) {
this.client = deps.client
this.subscribeFeed = deps.subscribeFeed
this.subscribeTurnFeed = deps.subscribeTurnFeed
this.subscribeDeltas = deps.subscribeDeltas
}
// Feed attachment is effect-managed and idempotent so React StrictMode's
@ -72,11 +83,28 @@ export class SessionChatStore {
// subscription would be torn down by the first cleanup and never restored).
connect(): () => void {
if (!this.feedDisconnect) {
this.feedDisconnect = this.subscribeFeed(this.onFeedEvent)
this.feedDisconnect = this.subscribeTurnFeed(this.onTurnEvent)
this.syncDeltas()
}
return () => {
this.feedDisconnect?.()
this.feedDisconnect = null
this.syncDeltas()
}
}
// Keep exactly one delta subscription: the latest turn, while connected.
private syncDeltas(): void {
const want =
this.feedDisconnect && this.latestEvents
? this.latestEvents[0].turnId
: null
if (want === this.deltaTurnId) return
this.deltaUnsub?.()
this.deltaUnsub = null
this.deltaTurnId = want
if (want) {
this.deltaUnsub = this.subscribeDeltas(want)
}
}
@ -99,6 +127,7 @@ export class SessionChatStore {
this.overlay = emptyOverlay()
this.error = null
this.loading = sessionId !== null
this.syncDeltas()
this.emit()
if (sessionId === null) return
@ -112,6 +141,7 @@ export class SessionChatStore {
this.priorTurns = reduced.slice(0, -1)
this.latestEvents = turns.length > 0 ? turns[turns.length - 1].events : null
this.loading = false
this.syncDeltas()
this.emit()
} catch (error) {
if (generation !== this.generation) return
@ -122,23 +152,37 @@ export class SessionChatStore {
}
}
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
if (event.kind !== 'turn-event' || event.sessionId !== this.sessionId) return
private onTurnEvent = (event: TurnBusEvent): void => {
if (this.sessionId === null || event.sessionId !== this.sessionId) return
const turnEvent = event.event
if (isDurable(turnEvent)) {
if (isDurableTurnEvent(turnEvent) && event.offset !== undefined) {
if (turnEvent.type === 'turn_created') {
// A new turn started for this session: freeze the previous latest.
this.freezeLatest()
this.latestEvents = [turnEvent]
this.overlay = emptyOverlay()
} else if (this.latestEvents && this.latestEvents[0].turnId === turnEvent.turnId) {
this.latestEvents.push(turnEvent)
this.syncDeltas()
} else if (this.latestEvents && this.latestEvents[0].turnId === event.turnId) {
// Offset join against the local log: drop already-known events,
// append the contiguous next line, refetch on a gap.
if (event.offset <= this.latestEvents.length) {
return
}
if (event.offset === this.latestEvents.length + 1) {
this.latestEvents.push(turnEvent)
} else {
void this.reloadTurn(event.turnId)
return
}
} else {
// An event for a turn we haven't seen (missed turn_created, e.g. the
// feed attached mid-turn): reconcile by refetching that turn.
void this.reloadTurn(event.turnId)
return
}
} else if (!this.latestEvents || this.latestEvents[0].turnId !== event.turnId) {
// Deltas are ephemeral: only the latest turn's deltas paint the overlay.
return
}
this.overlay = applyOverlay(this.overlay, turnEvent)
this.emit()
@ -164,6 +208,7 @@ export class SessionChatStore {
this.freezeLatest()
}
this.latestEvents = turn.events
this.syncDeltas()
this.emit()
} catch (error) {
// The next snapshot-worthy event will retry.
@ -237,10 +282,6 @@ export class SessionChatStore {
}
}
function isDurable(event: TurnStreamEvent): event is TEvent {
return event.type !== 'text_delta' && event.type !== 'reasoning_delta'
}
// ---------------------------------------------------------------------------
// Session list store
// ---------------------------------------------------------------------------
@ -250,6 +291,12 @@ export interface SessionListSnapshot {
loading: boolean
}
export interface SessionListStoreDeps {
client: SessionsClient
// sessions:events — index-changed entries for the session list.
subscribeFeed: (listener: SessionFeedListener) => () => void
}
export class SessionListStore {
private readonly client: SessionsClient
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
@ -259,7 +306,7 @@ export class SessionListStore {
private loading = true
private snapshot: SessionListSnapshot = { sessions: [], loading: true }
constructor(deps: SessionChatStoreDeps) {
constructor(deps: SessionListStoreDeps) {
this.client = deps.client
this.subscribeFeed = deps.subscribeFeed
}

View file

@ -67,6 +67,7 @@ export function completed(
turnId: string,
index: number,
message: { role: 'assistant'; content: unknown },
usage: Extract<TEvent, { type: 'model_call_completed' }>['usage'] = {},
): TEvent {
return {
type: 'model_call_completed',
@ -75,7 +76,7 @@ export function completed(
modelCallIndex: index,
message: message as never,
finishReason: 'stop',
usage: {},
usage,
}
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { reduceTurn } from '@x/shared/src/turns.js'
import { isChatMessage, isErrorMessage, isToolCall } from '@/lib/chat-conversation'
import { isChatMessage, isErrorMessage, isToolCall, isTurnUsageMessage } from '@/lib/chat-conversation'
import {
applyOverlay,
buildSessionChatState,
@ -179,6 +179,34 @@ describe('buildTurnConversation', () => {
expect(tool?.input).toEqual({ x: 1 })
})
it('adds one turn usage row from accumulated model-call usage', () => {
const state = reduceTurn([
created(T1, S1, user('run it')),
requested(T1, 0),
completed(T1, 0, assistantText('checking'), {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
}),
requested(T1, 1, ['assistant:0']),
completed(T1, 1, assistantText('done'), {
inputTokens: 12,
outputTokens: 3,
totalTokens: 15,
cachedInputTokens: 4,
}),
turnCompleted(T1, 'done'),
])
const usage = buildTurnConversation(state).find(isTurnUsageMessage)
expect(usage?.usage).toEqual({
inputTokens: 22,
outputTokens: 8,
totalTokens: 30,
cachedInputTokens: 4,
})
expect(usage?.modelCallCount).toBe(2)
})
it('marks permission-pending tools pending and running tools running', () => {
const state = reduceTurn([
created(T1, S1),
@ -388,6 +416,37 @@ describe('buildSessionChatState', () => {
expect(state.isWaitingOnHuman).toBe(false)
})
it('aggregates usage across session turns', () => {
const first = reduceTurn([
created('turn-1', S1, user('first?')),
requested('turn-1', 0),
completed('turn-1', 0, assistantText('first answer'), {
inputTokens: 20,
outputTokens: 5,
totalTokens: 25,
}),
turnCompleted('turn-1', 'first answer'),
])
const second = reduceTurn([
created('turn-2', S1, user('second?')),
requested('turn-2', 0),
completed('turn-2', 0, assistantText('second answer'), {
inputTokens: 30,
outputTokens: 7,
totalTokens: 37,
reasoningTokens: 2,
}),
turnCompleted('turn-2', 'second answer'),
])
const state = buildSessionChatState([first, second], emptyOverlay())
expect(state.sessionUsage).toEqual({
inputTokens: 50,
outputTokens: 12,
totalTokens: 62,
reasoningTokens: 2,
})
})
it('is settled (not processing) when the latest turn is terminal', () => {
const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a'))
const state = buildSessionChatState([turn], emptyOverlay())

View file

@ -20,8 +20,10 @@ import type {
ErrorMessage,
MessageAttachment,
PermissionResponse,
TokenUsage,
ToolCall,
} from '@/lib/chat-conversation'
import { addTokenUsage, hasTokenUsage } from '@/lib/token-usage'
// Pure derivations from reduced turn state (+ the ephemeral live overlay) to
// the view shapes the existing chat components already consume. No IPC, no
@ -337,6 +339,18 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
} satisfies ErrorMessage)
}
if (hasTokenUsage(state.usage)) {
const reasoningEffort = state.definition.config.reasoningEffort
items.push({
id: `${turnId}:usage`,
kind: 'turn-usage',
usage: state.usage,
modelCallCount: state.modelCalls.filter((call) => call.usage !== undefined).length,
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
timestamp: ts(),
})
}
return items
}
@ -349,6 +363,7 @@ type PermMeta = z.infer<typeof ToolPermissionMetadata>
export type SessionChatState = {
conversation: ConversationItem[]
currentAssistantMessage: string
sessionUsage: TokenUsage
// See LiveOverlay.voiceSegments.
voiceSegments: string[]
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
@ -410,8 +425,10 @@ export function buildSessionChatState(
overlay: LiveOverlay,
): SessionChatState {
const conversation: ConversationItem[] = []
let sessionUsage: TokenUsage = {}
for (const turn of turns) {
conversation.push(...buildTurnConversation(turn))
sessionUsage = addTokenUsage(sessionUsage, turn.usage)
}
for (let i = 0; i < conversation.length; i++) {
const item = conversation[i]
@ -482,6 +499,7 @@ export function buildSessionChatState(
return {
conversation,
currentAssistantMessage: stripVoiceTags(overlay.text),
sessionUsage,
voiceSegments: overlay.voiceSegments,
pendingAskHumanRequests,
allPermissionRequests,

View file

@ -0,0 +1,33 @@
import type { TokenUsage } from '@/lib/chat-conversation'
const USAGE_KEYS = [
'inputTokens',
'outputTokens',
'totalTokens',
'reasoningTokens',
'cachedInputTokens',
] as const
export function addTokenUsage(total: TokenUsage, usage: TokenUsage): TokenUsage {
const next = { ...total }
for (const key of USAGE_KEYS) {
const value = usage[key]
if (value !== undefined) {
next[key] = (next[key] ?? 0) + value
}
}
return next
}
export function hasTokenUsage(usage: TokenUsage | undefined): usage is TokenUsage {
return !!usage && USAGE_KEYS.some((key) => (usage[key] ?? 0) > 0)
}
export function totalTokensOf(usage: TokenUsage): number {
return usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
}
export function formatTokenCount(tokens: number | undefined): string {
if (tokens === undefined) return '-'
return new Intl.NumberFormat('en-US', { notation: 'compact' }).format(tokens)
}

View file

@ -0,0 +1,17 @@
import type { TurnBusEvent } from '@x/shared/src/turns.js'
import { createBroadcastFeed, type FeedListener } from '@/lib/broadcast-feed'
// The turn event spine: durable events of every turn the runtime executes —
// session chat, headless background/knowledge runners, spawned sub-agents —
// tagged with sessionId and the event's 1-based file offset. Consumers join a
// live turn by subscribing first, fetching the sessions:getTurn snapshot, and
// discarding feed events whose offset is already covered by the snapshot
// (see useTurn).
const appFeed = createBroadcastFeed<TurnBusEvent>((listener) =>
window.ipc.on('turns:events', listener),
)
export function subscribeTurnFeed(listener: FeedListener<TurnBusEvent>): () => void {
return appFeed.subscribe(listener)
}

View file

@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest'
import type { TurnBusEvent, TurnState } from '@x/shared/src/turns.js'
import { followTurn, type TurnFollowerDeps } from './turn-follower'
import {
completed,
created,
requested,
turnCompleted,
assistantText,
user,
type TEvent,
} from './session-chat/test-fixtures'
const TURN = 't1'
function log(): TEvent[] {
return [
created(TURN, 's1', user('question')),
requested(TURN, 0),
completed(TURN, 0, assistantText('answer')),
turnCompleted(TURN, 'answer'),
]
}
function bus(event: TEvent, offset: number): TurnBusEvent {
return { turnId: TURN, sessionId: 's1', event, offset }
}
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
function harness(fetches: Array<TEvent[] | Error>) {
const fetchTurn = vi.fn(async () => {
const next = fetches.shift()
if (!next || next instanceof Error) {
throw next ?? new Error('no snapshot scripted')
}
return { events: [...next] }
})
let listener: ((event: TurnBusEvent) => void) | null = null
let unsubscribed = false
const states: TurnState[] = []
const errors: string[] = []
const snapshotFailures: string[] = []
const deps: TurnFollowerDeps = {
fetchTurn,
subscribe: (fn) => {
listener = fn
return () => {
unsubscribed = true
listener = null
}
},
onState: (state) => states.push(state),
onError: (message) => errors.push(message),
onSnapshotFailed: (message) => snapshotFailures.push(message),
maxRetries: 0,
}
return {
deps,
fetchTurn,
states,
errors,
snapshotFailures,
emit: (event: TurnBusEvent) => listener?.(event),
get unsubscribed() {
return unsubscribed
},
}
}
describe('followTurn', () => {
it('publishes the snapshot, then extends it with contiguous feed events', async () => {
const full = log()
const h = harness([full.slice(0, 2)])
const detach = followTurn(TURN, h.deps)
await flush()
expect(h.states).toHaveLength(1)
expect(h.states[0].modelCalls[0].response).toBeUndefined()
h.emit(bus(full[2], 3))
h.emit(bus(full[3], 4))
expect(h.states).toHaveLength(3)
const final = h.states[2]
expect(final.terminal?.type).toBe('turn_completed')
expect(h.errors).toEqual([])
detach()
})
it('discards feed events already covered by the snapshot', async () => {
const full = log()
const h = harness([full])
const detach = followTurn(TURN, h.deps)
await flush()
// Replays of already-snapshotted lines must not corrupt the reduction.
h.emit(bus(full[2], 3))
h.emit(bus(full[3], 4))
expect(h.errors).toEqual([])
expect(h.states[h.states.length - 1].terminal?.type).toBe('turn_completed')
detach()
})
it('buffers events that arrive before the snapshot and applies them after', async () => {
const full = log()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const h = harness([])
h.deps.fetchTurn = vi.fn(async () => {
await gate
return { events: full.slice(0, 3) }
})
const detach = followTurn(TURN, h.deps)
h.emit(bus(full[3], 4)) // arrives mid-fetch
release()
await flush()
expect(h.states[h.states.length - 1].terminal?.type).toBe('turn_completed')
expect(h.errors).toEqual([])
detach()
})
it('refetches the snapshot on a contiguity gap', async () => {
const full = log()
const h = harness([full.slice(0, 2), full])
const detach = followTurn(TURN, h.deps)
await flush()
// Offset 4 while the local log has 2 entries: events were missed.
h.emit(bus(full[3], 4))
await flush()
expect(h.fetchTurn).toHaveBeenCalledTimes(2)
expect(h.states[h.states.length - 1].terminal?.type).toBe('turn_completed')
expect(h.errors).toEqual([])
detach()
})
it('recovers from a failed snapshot when a feed event arrives', async () => {
const full = log()
const h = harness([new Error('turn file not created yet'), full])
const detach = followTurn(TURN, h.deps)
await flush()
expect(h.states).toHaveLength(0)
h.emit(bus(full[3], 4))
await flush()
expect(h.fetchTurn).toHaveBeenCalledTimes(2)
expect(h.states[h.states.length - 1].terminal?.type).toBe('turn_completed')
detach()
})
it('reports snapshot failure once retries are exhausted', async () => {
const h = harness([new Error('turn not found: legacy run id')])
const detach = followTurn(TURN, h.deps)
await flush()
// maxRetries 0: the first failure is definitive (legacy-run fallback
// hinges on this signal firing).
expect(h.snapshotFailures).toEqual(['turn not found: legacy run id'])
expect(h.states).toHaveLength(0)
detach()
})
it('stops delivering after detach and unsubscribes from the feed', async () => {
const full = log()
const h = harness([full.slice(0, 2)])
const detach = followTurn(TURN, h.deps)
await flush()
expect(h.states).toHaveLength(1)
detach()
expect(h.unsubscribed).toBe(true)
})
})

View file

@ -0,0 +1,134 @@
import type { z } from 'zod'
import {
isDurableTurnEvent,
reduceTurn,
type TurnBusEvent,
type TurnEvent,
} from '@x/shared/src/turns.js'
import type { TurnState } from '@x/shared/src/turns.js'
// Follows one turn live, regardless of where it runs — session chat, headless
// background/knowledge runners, spawned sub-agents.
//
// Join protocol: subscribe to the turn feed first (buffering), fetch the
// durable snapshot, then extend the snapshot with feed events. Each durable
// feed event carries its 1-based file offset, so events already covered by
// the snapshot are discarded and a contiguity gap (e.g. the subscription
// raced turn start) falls back to a snapshot refetch. A failed snapshot fetch
// retries a few times and again on the next feed event for this turn, so a
// turn whose file is still being created self-heals.
export interface TurnFollowerDeps {
fetchTurn: (turnId: string) => Promise<{ events: Array<z.infer<typeof TurnEvent>> }>
subscribe: (listener: (event: TurnBusEvent) => void) => () => void
onState: (state: TurnState) => void
onError: (message: string) => void
// Fired when snapshot retries are exhausted (the id may not be a turn at
// all — e.g. a pre-migration legacy run). A later feed event still retries
// and can recover.
onSnapshotFailed?: (message: string) => void
retryDelayMs?: number
maxRetries?: number
}
const DEFAULT_RETRY_DELAY_MS = 1000
const DEFAULT_MAX_RETRIES = 3
export function followTurn(turnId: string, deps: TurnFollowerDeps): () => void {
const retryDelayMs = deps.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
const maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES
let alive = true
// null until a snapshot lands; feed events buffer in `pending` meanwhile.
let events: Array<z.infer<typeof TurnEvent>> | null = null
let pending: TurnBusEvent[] = []
let fetching = false
let retries = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
const publish = () => {
if (!alive || !events) return
try {
deps.onState(reduceTurn(events))
} catch (err) {
deps.onError(err instanceof Error ? err.message : String(err))
}
}
// Extends `events` with one feed event; false signals a contiguity gap.
const apply = (e: TurnBusEvent): boolean => {
if (!events || e.offset === undefined || !isDurableTurnEvent(e.event)) {
return true
}
if (e.offset <= events.length) {
return true // already covered by the snapshot
}
if (e.offset === events.length + 1) {
events.push(e.event)
return true
}
return false
}
const resync = () => {
events = null
void fetchSnapshot()
}
const fetchSnapshot = async () => {
if (fetching || !alive) return
fetching = true
try {
const turn = await deps.fetchTurn(turnId)
if (!alive) return
retries = 0
events = turn.events
const buffered = pending
pending = []
for (const e of buffered) {
if (!apply(e)) {
fetching = false
resync()
return
}
}
publish()
} catch (err) {
if (!alive) return
// Turn file may not exist yet (subscription raced creation) or the
// fetch failed transiently.
events = null
if (retries < maxRetries) {
retries += 1
retryTimer = setTimeout(() => void fetchSnapshot(), retryDelayMs)
} else {
deps.onSnapshotFailed?.(err instanceof Error ? err.message : String(err))
}
} finally {
fetching = false
}
}
const unsubscribe = deps.subscribe((e) => {
if (!alive || e.turnId !== turnId) return
if (!isDurableTurnEvent(e.event) || e.offset === undefined) return
if (!events) {
pending.push(e)
void fetchSnapshot()
return
}
if (apply(e)) {
publish()
} else {
pending = [e]
resync()
}
})
void fetchSnapshot()
return () => {
alive = false
if (retryTimer) clearTimeout(retryTimer)
unsubscribe()
}
}

View file

@ -7,6 +7,7 @@ import type { CaptureResult } from 'posthog-js'
import { ThemeProvider } from '@/contexts/theme-context'
import { configureAnalyticsContext } from './lib/analytics'
import { VideoPopout } from '@/components/video-popout'
import { MeetingDetectedPopup } from '@/components/meeting-detected-popup'
// Fetch the stable installation ID from main so renderer + main share one
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
@ -66,6 +67,13 @@ if (window.location.hash === '#video-popout') {
<VideoPopout />
</StrictMode>,
)
} else if (window.location.hash === '#meeting-detected') {
// "Meeting detected — Take Notes?" popup window; same pattern.
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MeetingDetectedPopup />
</StrictMode>,
)
} else {
bootstrap()
}

BIN
apps/x/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

View file

@ -16,7 +16,11 @@
"test": "npm run shared && npm run test:shared && npm run test:core && npm run test:renderer",
"test:shared": "cd packages/shared && npm test",
"test:core": "cd packages/core && npm test",
"test:renderer": "cd apps/renderer && npm test"
"test:renderer": "cd apps/renderer && npm test",
"typecheck": "npm run shared && npm run typecheck:shared && npm run typecheck:core && npm run typecheck:renderer",
"typecheck:shared": "cd packages/shared && npm run typecheck",
"typecheck:core": "cd packages/core && npm run typecheck",
"typecheck:renderer": "cd apps/renderer && npm run typecheck"
},
"devDependencies": {
"@eslint/js": "^9.39.2",

View file

@ -338,9 +338,14 @@ runtime's job; the session layer passes its errors through.
### 9.3 stopTurn and resumeTurn
- `stopTurn` cancels via the turn runtime: aborting the active invocation's
signal if one is running, else advancing a suspended turn with a `cancel`
input.
- `stopTurn` cancels via the turn runtime: aborting the signal of every
live advance the layer has started for the turn, else advancing the turn
with a `cancel` input. A turn can legally have several live advances at
once — one running invocation plus external-input invocations queued on
the turn lock — so the layer tracks them per turn and stop aborts them
all; the queued ones observe their aborted signal when the lock frees. A
cancel input that loses the race with a concurrent settle (the turn is
already terminal by the time it applies) counts as a successful stop.
- `resumeTurn` re-enters the latest turn with no input — the turn spec's
recovery entry point — for turns left `idle` by a crash. There is no
automatic resume sweep at startup: recovery re-issues interrupted model
@ -357,18 +362,23 @@ which governs mutation of live logs, not their removal.
## 10. Event forwarding and live UI
1. For every `advanceTurn` it initiates, the session layer consumes
`TurnExecution.events` and forwards each `TurnStreamEvent` — tagged with
`sessionId` — through the application bus to renderer windows over one
IPC channel (`sessions:events`).
1. Live turn delivery is not the session layer's job: the turn runtime
publishes every turn's events to the process-wide turn event bus
(turn-runtime-design.md §17.1), which the app layer bridges to renderer
windows over one IPC channel (`turns:events` — durable events broadcast
with their file offsets; deltas only to windows subscribed to that turn).
The session layer drains each `TurnExecution.events` it initiates so an
unconsumed stream never buffers, and `sessions:events` carries only
`session-index-changed` entries.
2. When `outcome` settles, the session layer updates the index entry and
publishes `session-index-changed`.
3. The renderer follows the turn spec's historical/live pattern: fetch
turns via `getTurn`, run the shared `reduceTurn` per turn, compose the
session timeline turn-by-turn (each turn renders its input and its own
activity; the referenced prefix is never re-rendered from context),
append live durable events and re-reduce, and keep text/reasoning deltas
in an ephemeral overlay cleared by canonical responses.
join live durable events by file offset (drop covered, append
contiguous, refetch on gap) and re-reduce, and keep text/reasoning
deltas in an ephemeral overlay cleared by canonical responses.
4. Pending approvals and ask-human prompts render from the suspended turn's
reduced state, so they survive restarts without any session-layer
bookkeeping.
@ -376,7 +386,10 @@ which governs mutation of live logs, not their removal.
## 11. Headless standalone turns
A helper covers the non-session callers (background tasks, live notes,
knowledge pipelines, scheduled agents):
knowledge pipelines, scheduled agents). Implemented as
`HeadlessAgentRunner` in `agents/headless.ts` (start/run handle with
turn id, reduced state, and final assistant text); the shape below is
the contract it fulfils:
```ts
function runHeadlessTurn(input: {
@ -491,6 +504,11 @@ All tests use the in-memory/mocked turn runtime and repo fakes.
correct turn with the correct input type.
- Turn-runtime rejections (unknown call, terminal turn) pass through.
- `sendMessage` never routes to ask-human.
- `stopTurn` aborts every live advance when a turn has concurrent
invocations, and an earlier advance settling does not untrack a later
one.
- A `stopTurn` cancel input that lost the race with a concurrent settle
resolves as a successful stop; a non-terminal rejection still surfaces.
### 13.6 Index
@ -512,16 +530,21 @@ All tests use the in-memory/mocked turn runtime and repo fakes.
## 14. Suggested module layout
```text
apps/x/packages/shared/src/sessions.ts # event schemas, reducer, index types
apps/x/packages/shared/src/sessions.ts # event schemas, reducer, index types
apps/x/packages/core/src/sessions/
apps/x/packages/core/src/runtime/sessions/
sessions.ts # ISessions implementation
api.ts # public contract
repo.ts # ISessionRepo contract
fs-repo.ts # filesystem implementation
index.ts # in-memory index + startup scan
headless.ts # runHeadlessTurn
session-index.ts # in-memory index
bus.ts # index-changed fan-out
```
The headless helper of §11 is implemented as `HeadlessAgentRunner` in
`runtime/assembly/headless.ts` (not under `sessions/`).
Awilix registration mirrors the turn runtime: singleton scope, PROXY
constructor injection, no container resolution from inside the classes.

View file

@ -1,9 +1,10 @@
# Turn Runtime Technical Specification
Status: implemented and live. All chat, background, and knowledge callers
run on this runtime; the legacy runs runtime remains only for code-mode
sessions (see the carve-out section in the repo-root `AGENTS.md`). The
companion session layer is specified in `session-design.md`.
run on this runtime; the legacy runs runtime (`src/runtime/legacy/`) remains
only for code-mode sessions and the mini-apps host API, and is deleted as a
unit when those migrate. The companion session layer is specified in
`session-design.md`.
This document specifies a new turn-oriented agent loop for `@x/core`. It is
intended to replace the behavioral responsibilities of the current run runtime
@ -163,10 +164,18 @@ naturally complete independently. No behavior may rely on physical completion
order.
Durable appends are serialized through a single internal queue per invocation:
the persist → reduce → stream ritual runs to completion for one batch of
the reduce → persist → stream ritual runs to completion for one batch of
events before the next begins, so file order, in-memory order, and stream
order are identical by construction even while executions overlap.
The reduce step comes first as a validation gate: the batch is reduced
against the in-memory history before anything is written, so an illegal
append (for example, a misbehaving tool reporting progress after its
terminal result) rejects in memory for its caller only and never becomes
durable. A persisted illegal event would make every future read of the
file fail and, through context references (section 6.6), block the whole
session chain behind it.
## 5. Storage design
### 5.1 File location
@ -597,6 +606,13 @@ bytes and the two views cannot diverge.
Requests exclude credentials, auth headers, functions, model objects, and
transport objects.
`parameters` holds only canonical, provider-agnostic generation knobs. The
model bridge whitelists what it forwards (`temperature`, `topP`,
`maxOutputTokens`, `providerOptions`) and maps `reasoningEffort`
(`"low" | "medium" | "high"`, stamped from `turn_created.config` onto every
call) into provider-specific options at invoke time — transport-only, like
prompt caching, so a persisted turn replays correctly on a different model.
The name `requested` is intentional. The event proves durable intent, not that
the provider definitely received the request.
@ -684,6 +700,15 @@ interface IPermissionChecker {
Tool-specific policy, command analysis, filesystem boundaries, and allowlists
remain outside the loop.
The real checker bridge implements that policy from per-tool declarations in
the builtin catalog (`tools/types.ts`) and fails closed: any tool without an
explicit `"none"` declaration — undeclared builtins, `mcp:*` attachments on
user agents, unknown toolId families — requires permission. Composio and MCP
executions produce family-specific request payloads (the shared
`ToolPermissionMetadata` kinds); everything else falls back to a generic
`{kind: "tool"}` request. The audited set of gated builtins is pinned by a
catalog test.
When automatic permission is enabled, the injected classifier handles all
permission-required calls from one model response in one batch:
@ -1300,6 +1325,10 @@ Missing or mismatched runtime dependencies:
- Do not append `turn_failed`.
- Leave the turn unchanged so the caller can fix its environment and retry.
Cancel inputs are exempt from this validation: they are applied before
dependency materialization (section 22), because cancellation is the
terminal exit for environments that never come back.
Provider failures after actual execution begins are modeled turn failures.
## 16. Hot TurnExecution stream
@ -1415,6 +1444,34 @@ Lifecycle publication is observational and must not alter durable turn
semantics. Live durable events and deltas are consumed from `TurnExecution` by
the application and may be forwarded over the existing bus/IPC bridge.
### 17.1 Turn event bus
Implemented alongside the lifecycle bus: the runtime publishes every turn's
events to an injected `ITurnEventBus` (`event-hub.ts`), tagged with the
turn's `sessionId`, regardless of who started the turn — session chat,
headless runners, spawned sub-agents. This is the process-wide delivery
spine; `TurnExecution` remains the single-consumer, invocation-scoped
stream for the initiating caller.
- `turn_created` is published by `createTurn` (it never flows through an
execution stream); every other durable event is published by the advance
loop immediately after its `stream.push`, inside the serialized commit
ritual, so bus order equals file order.
- Durable events carry `offset`, their 1-based line index in the turn file
(`this.events.length` at commit time, which is absolute because each
invocation re-reads the full log). A late subscriber joins a live turn by
subscribing first, fetching the `getTurn` snapshot, and discarding bus
events with `offset <= snapshot.length` — no gaps, no duplicates, no
sequence numbers in the durable schema.
- Text/reasoning deltas are published without an offset (they are not
durable). The app-layer IPC bridge (`turns:events`) broadcasts durable
events to every window; deltas are forwarded only to windows that
declared they are watching that turn (`turns:subscribe` /
`turns:unsubscribe`, a per-webContents registry in the app layer).
- The bus is ephemeral and observational, like the lifecycle bus: listener
errors are swallowed, nothing durable depends on delivery, and a crash
losing listeners accurately reflects that no execution is known active.
## 18. Main execution algorithm
At a high level, `advanceTurn` performs:
@ -1425,10 +1482,13 @@ At a high level, `advanceTurn` performs:
3. Publish turn-processing-start.
4. Read and validate JSONL.
5. Reduce events to TurnState.
6. Materialize context through the injected context resolver.
7. Validate terminal state, input, and runtime dependencies.
8. Apply the optional single external input.
9. Repeatedly advance deterministic work:
6. Validate terminal state and the optional input.
7. If the input is a cancel, cancel immediately — live dependencies are
never materialized for cancellation (section 22).
8. Materialize context through the injected context resolver and validate
runtime dependencies.
9. Apply the optional single external input.
10. Repeatedly advance deterministic work:
a. Recover from the current durable boundary.
b. Resolve permissions.
c. Execute eligible sync tools.
@ -1441,9 +1501,9 @@ At a high level, `advanceTurn` performs:
j. Persist normalized non-delta events and stream deltas.
k. Append model_call_completed or model_call_failed.
l. Complete when the response has no tool calls.
10. Publish turn-processing-end in finalization.
11. Release the lock.
12. Resolve/reject outcome and close/error event stream.
11. Publish turn-processing-end in finalization.
12. Release the lock.
13. Resolve/reject outcome and close/error event stream.
```
The loop does not read session queues, accept new user messages, switch agents,
@ -1539,6 +1599,11 @@ Rules:
- Append `turn_cancelled`.
- Never initiate another model call.
- Reject late permission decisions, progress, and results after cancellation.
- A cancel input is applied before live-dependency materialization:
cancellation never requires the context, agent snapshot, model, or tools
to resolve, so a turn whose environment is no longer resolvable (provider
removed from config, builtin renamed, context chain unreadable) can
always be cancelled — the terminal exit that keeps its session usable.
Sync tools are cooperatively cancellable. A tool that ignores the signal may not
settle immediately. The runtime cannot guarantee rollback of external side
@ -1620,6 +1685,11 @@ but the first turn implementation does not enforce it.
The reducer validates `context` structurally but treats it as opaque; it
never resolves references (section 6.6).
The runtime also uses the reducer as its append gate: every batch is
reduced against the existing history before it is persisted (section 4.5),
so a history that violates these invariants cannot become durable through
the loop.
## 25. Historical and live UI behavior
### 25.1 Historical load
@ -1733,6 +1803,9 @@ Assertions:
- Turn becomes terminal cancelled.
- No subsequent model request occurs.
- Late external inputs are rejected.
- Cancellation succeeds when live dependencies can no longer be resolved,
while non-cancel inputs against the same broken environment reject with
the turn file unchanged.
### 26.6 Failures
@ -1853,21 +1926,20 @@ tests for:
This is a suggested organization, not a locked implementation requirement:
```text
apps/x/packages/shared/src/turns.ts
apps/x/packages/shared/src/turns.ts # durable schemas + reducer (unchanged home)
apps/x/packages/core/src/turns/
runtime.ts
reducer.ts # if implementation is re-exported through shared,
# locate pure code to avoid dependency cycles
repo.ts
fs-repo.ts
stream.ts
agent-resolver.ts
context-resolver.ts
model-registry.ts
tool-registry.ts
permission.ts
index.ts
apps/x/packages/core/src/runtime/
turns/ # the engine: runtime.ts, stream, event-hub, context
# resolution/elision, request composer, repos, inspect-cli
bridges/ # real implementations of the engine's seams (agent/tool/
# model resolvers, permission checker/classifier)
sessions/ # session layer (session-design.md)
assembly/ # what an agent is: registry, compose-instructions,
# workspace-context, message-encoding, permission-metadata,
# headless runners, spawn-agent, copilot/, capabilities/,
# skills/
tools/ # builtin-tool catalog + domain modules + exec plumbing
legacy/ # the dying runs engine (engine.ts + runs.ts + repos)
```
The final reducer location must permit both core and renderer to use exactly the

View file

@ -7,28 +7,29 @@
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -w -p tsconfig.build.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"inspect-turn": "node dist/turns/inspect-cli.js",
"inspect": "node dist/turns/inspect-cli.js"
"inspect-turn": "node dist/runtime/turns/inspect-cli.js",
"inspect": "node dist/runtime/turns/inspect-cli.js"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.55.0",
"@agentclientprotocol/codex-acp": "^1.1.0",
"@agentclientprotocol/sdk": "^1.1.0",
"@ai-sdk/anthropic": "^2.0.63",
"@ai-sdk/google": "^2.0.53",
"@ai-sdk/openai": "^2.0.91",
"@ai-sdk/openai-compatible": "^1.0.33",
"@ai-sdk/provider": "^2.0.1",
"@ai-sdk/anthropic": "^4.0.12",
"@ai-sdk/google": "^4.0.12",
"@ai-sdk/openai": "^4.0.11",
"@ai-sdk/openai-compatible": "^3.0.7",
"@ai-sdk/provider": "^4.0.3",
"@composio/core": "^0.6.0",
"@google-cloud/local-auth": "^3.0.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@openrouter/ai-sdk-provider": "^1.2.6",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@react-pdf/renderer": "^4.3.2",
"@types/react": "^19.2.7",
"@x/shared": "workspace:*",
"ai": "^5.0.133",
"ai": "^7.0.22",
"awilix": "^12.0.5",
"baileys": "7.0.0-rc13",
"chokidar": "^4.0.3",
@ -41,7 +42,7 @@
"isomorphic-git": "^1.29.0",
"mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
"ollama-ai-provider-v2": "^4.0.1",
"openid-client": "^6.8.1",
"papaparse": "^5.5.3",
"pdf-parse": "^2.4.5",

View file

@ -4,7 +4,7 @@ import { IAgentScheduleRepo } from "./repo.js";
import { IAgentScheduleStateRepo } from "./state-repo.js";
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
import { startWhenPossible } from "../agents/headless-app.js";
import { startWhenPossible } from "../runtime/assembly/headless-app.js";
import { withUseCase } from "../analytics/use_case.js";
import z from "zod";

View file

@ -1,52 +0,0 @@
export const skill = String.raw`
# Web Search Skill
You have access to two search tools for finding information on the internet. Choose the right one based on the user's intent.
## Tools
### web-search (Brave Search)
Quick, general-purpose web search. Returns titles, URLs, and short descriptions.
**Best for:**
- Quick lookups for things that change ("current price of Bitcoin", "weather in SF")
- Current events and breaking news
- Finding a specific website or page
- Simple questions with direct answers
- Checking a fact or date
### research-search (Exa Search)
Deep, research-oriented search. Returns full article text, highlights, and metadata (author, published date).
**Best for:**
- Exploring a topic in depth ("what are the latest advances in CRISPR")
- Finding articles, blog posts, papers, and quality sources
- Discovering companies, people, or organizations
- Research where you need rich context, not just links
- When the user says "research", "find articles about", "look into", "deep dive"
**Category filter:** Use the category parameter when the user's intent clearly maps to one: company, research paper, news, tweet, personal site, financial report, people.
## How Many Searches to Do
**CRITICAL: Always start with exactly ONE search call.** Pick the single best tool (\`web-search\` or \`research-search\`) and make one request. Wait for the result before deciding if more searches are needed.
**NEVER call multiple search tools simultaneously.** No parallel web-search + research-search. No firing off two web-searches at once. Always sequential: one search at a time.
Only make a follow-up search if:
- The first search returned truly uninformative or irrelevant results
- The query has clearly distinct sub-topics that the first search couldn't cover (e.g., "compare X and Y" after getting results for X only)
- The user explicitly asks you to dig deeper
One good search is almost always enough. Default to one and stop.
## Choosing Between the Two
If both tools are attached, prefer:
- \`web-search\` when the user wants a quick answer or specific link
- \`research-search\` when the user wants to learn, explore, or gather sources
If only one is attached, use whichever is available.
`;
export default skill;

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { exec, execSync, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
import { getExecutionShell } from '../../runtime/assembly/copilot/runtime-context.js';
const execPromise = promisify(exec);

View file

@ -0,0 +1,7 @@
// One home for the error -> human-readable message idiom. The legacy runs
// engine's getErrorDetails also unwrapped RunFailedError's multi-error list;
// live callers run agents through the headless runner, whose HeadlessRunError
// carries the failure detail in .message — so this is the whole contract.
export function getErrorDetails(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -19,8 +19,8 @@ export async function notifyIfEnabled(
): Promise<void> {
try {
if (!isNotificationCategoryEnabled(category)) return;
const { default: container } = await import('../../di/container.js');
const service = container.resolve<INotificationService>('notificationService');
const { lazyResolve } = await import('../../di/lazy-resolve.js');
const service = await lazyResolve<INotificationService>('notificationService');
if (!service.isSupported()) return;
service.notify(input);
} catch (err) {

View file

@ -26,8 +26,8 @@ import { createProvider } from '../models/models.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
import { isSignedIn } from '../account/account.js';
import { createRun, createMessage } from '../runs/runs.js';
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
import { createRun, createMessage } from '../runtime/legacy/runs.js';
import { extractAgentResponse, waitForRunCompletion } from '../runtime/legacy/utils.js';
import { getBackgroundTaskAgentModel } from '../models/defaults.js';
// Host API — M2 endpoints (spec §7.4§7.7): Composio tools, SSRF-guarded fetch
@ -304,8 +304,8 @@ async function handleLlmGenerate(
const model = createProvider(providerConfig).languageModel(resolved.model);
const result = await withUseCase({ useCase: 'app_llm_generate', subUseCase: slug }, () => generateText({
model,
...(system ? { system } : {}),
...(rawMessages ? { messages: rawMessages as ModelMessage[] } : { prompt: prompt as string }),
...(system ? { instructions: system } : {}),
...(rawMessages ? { messages: rawMessages as ModelMessage[], allowSystemInMessages: true } : { prompt: prompt as string }),
...(temperature !== undefined ? { temperature } : {}),
maxOutputTokens,
}));

View file

@ -683,10 +683,48 @@ function slugFromAbsolutePath(absolutePath: string): { slug: string; rel: string
return { slug, rel: segments.slice(1).join('/') };
}
// Config-change → one-shot agent run. An app writes data/config.json when the
// user changes its settings (e.g. picks the repo to track); its bundled agents
// are what turn that config into fresh data. Without this kick the user would
// stare at an empty app until the next cron tick. Generic: any app, any agent
// the app owns (app--<slug>--*). Dynamic import keeps apps/server decoupled
// from the bg-task runner at module-load time.
const agentKickTimers = new Map<string, NodeJS.Timeout>();
function scheduleAgentKick(slug: string): void {
const existing = agentKickTimers.get(slug);
if (existing) clearTimeout(existing);
agentKickTimers.set(slug, setTimeout(() => {
agentKickTimers.delete(slug);
void (async () => {
try {
const tasksDir = path.join(path.dirname(APPS_DIR), 'bg-tasks');
const entries = await fsp.readdir(tasksDir).catch(() => [] as string[]);
const owned = entries.filter((e) => e.startsWith(`app--${slug}--`));
if (!owned.length) return;
const { runBackgroundTask } = await import('../background-tasks/runner.js');
for (const taskSlug of owned) {
console.log(`[Apps] ${slug}/data/config.json changed — running ${taskSlug}`);
void runBackgroundTask(taskSlug, 'manual').catch((e: unknown) => {
console.warn(`[Apps] config-change agent run failed for ${taskSlug}:`, e);
});
}
} catch (e) {
console.warn(`[Apps] config-change agent kick failed for ${slug}:`, e);
}
})();
}, 400));
}
async function startWatcher(): Promise<void> {
if (watcher) return;
const w = chokidar.watch(APPS_DIR, {
ignoreInitial: true,
// Installed apps may ship .git/node_modules trees — thousands of files
// no consumer renders, at one watch fd per file (chokidar v4, no fsevents).
ignored: (watchedPath: string) => {
const segments = path.relative(APPS_DIR, watchedPath).split(path.sep);
return segments.includes('.git') || segments.includes('node_modules');
},
awaitWriteFinish: { stabilityThreshold: 180, pollInterval: 50 },
});
w.on('all', (eventName, absolutePath) => {
@ -695,6 +733,9 @@ async function startWatcher(): Promise<void> {
if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return;
const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist';
scheduleChangeBroadcast(hit.slug, area, hit.rel);
if (hit.rel === 'data/config.json' && (eventName === 'add' || eventName === 'change')) {
scheduleAgentKick(hit.slug);
}
});
w.on('error', (error: unknown) => {
console.error('[Apps] watcher error:', error);
@ -795,6 +836,8 @@ export async function shutdown(): Promise<void> {
for (const timer of reloadTimers.values()) clearTimeout(timer);
reloadTimers.clear();
for (const timer of agentKickTimers.values()) clearTimeout(timer);
agentKickTimers.clear();
for (const clients of eventClients.values()) {
for (const res of clients) {

View file

@ -1,6 +1,6 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../application/lib/builtin-tools.js';
import { BuiltinTools } from '../runtime/tools/catalog.js';
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../application/lib/knowledge-note-style.js';
import { WorkDir } from '../config/config.js';

View file

@ -5,7 +5,7 @@ import container from '../di/container.js';
import type { CodeSessionService } from '../code-mode/sessions/service.js';
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
import * as gitService from '../code-mode/git/service.js';
import { extractAgentResponse } from '../agents/utils.js';
import { extractAgentResponse } from '../runtime/legacy/utils.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { fetchTask, taskIndexPath } from './fileops.js';

View file

@ -2,8 +2,8 @@ import type { BackgroundTask, BackgroundTaskTriggerType } from '@x/shared/dist/b
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import { fetchTask, patchTask, prependRunId } from './fileops.js';
import { getBackgroundTaskAgentModel } from '../models/defaults.js';
import { startHeadlessAgent, startWhenPossible } from '../agents/headless-app.js';
import { buildTriggerBlock } from '../agents/build-trigger-block.js';
import { startHeadlessAgent, startWhenPossible } from '../runtime/assembly/headless-app.js';
import { buildTriggerBlock } from '../runtime/assembly/build-trigger-block.js';
import { backgroundTaskBus } from './bus.js';
import { withUseCase } from '../analytics/use_case.js';
@ -128,8 +128,8 @@ export async function runBackgroundTask(
let codeProject: { id: string; path: string; name: string } | undefined;
if (task.projectId) {
try {
const { default: container } = await import('../di/container.js');
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
const { lazyResolve } = await import('../di/lazy-resolve.js');
const projectsRepo = await lazyResolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
} catch (err) {

View file

@ -1,9 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionIndexEntry } from "@x/shared/dist/sessions.js";
import type { TurnStreamEvent } from "@x/shared/dist/turns.js";
import { EmitterSessionBus } from "../sessions/bus.js";
import { TurnInputError } from "../turns/api.js";
import type { ISessions } from "../sessions/api.js";
import { TurnEventHub } from "../runtime/turns/event-hub.js";
import { TurnInputError } from "../runtime/turns/api.js";
import type { ISessions } from "../runtime/sessions/api.js";
import { ChannelBridge, type ModelChoice } from "./bridge.js";
const SENDER = "test:1";
@ -50,7 +50,7 @@ function askEvent(turnId: string, question: string, options?: string[]): TurnStr
interface Harness {
bridge: ChannelBridge;
bus: EmitterSessionBus;
bus: TurnEventHub;
replies: string[];
reply: (text: string) => Promise<void>;
sessions: {
@ -72,9 +72,9 @@ const MODELS: ModelChoice[] = [
];
function harness(entries: SessionIndexEntry[] = []): Harness {
const bus = new EmitterSessionBus();
const bus = new TurnEventHub();
const publish = (turnId: string, event: TurnStreamEvent) =>
bus.publish({ kind: "turn-event", sessionId: "s1", turnId, event });
bus.publish({ turnId, sessionId: "s1", event });
const sessions = {
createSession: vi.fn(async () => "s1"),
sendMessage: vi.fn(async () => ({ turnId: "t1" })),
@ -86,7 +86,7 @@ function harness(entries: SessionIndexEntry[] = []): Harness {
const listModels = vi.fn(async () => MODELS);
const bridge = new ChannelBridge({
sessions: sessions as unknown as ISessions,
sessionBus: bus,
turnEventBus: bus,
listModels,
});
const replies: string[] = [];

View file

@ -1,10 +1,10 @@
import type { SessionIndexEntry } from "@x/shared/dist/sessions.js";
import { reduceTurn, type TurnStreamEvent } from "@x/shared/dist/turns.js";
import { assistantText, lastAssistantText } from "../agents/headless.js";
import { TurnInputError } from "../turns/api.js";
import { ASK_HUMAN_TOOL } from "../turns/bridges/real-agent-resolver.js";
import { TurnNotSettledError, type ISessions } from "../sessions/api.js";
import type { EmitterSessionBus } from "../sessions/bus.js";
import { assistantText, lastAssistantText } from "../runtime/assembly/headless.js";
import { TurnInputError } from "../runtime/turns/api.js";
import { ASK_HUMAN_TOOL } from "../runtime/turns/bridges/real-agent-resolver.js";
import { TurnNotSettledError, type ISessions } from "../runtime/sessions/api.js";
import type { ITurnEventBus } from "../runtime/turns/event-hub.js";
// Transport-agnostic command layer: inbound texts from a messaging channel
// are parsed into commands (list / resume / new / stop / status) or forwarded
@ -136,7 +136,7 @@ export class ChannelBridge {
constructor(
private readonly deps: {
sessions: ISessions;
sessionBus: EmitterSessionBus;
turnEventBus: ITurnEventBus;
listModels: () => Promise<ModelChoice[]>;
},
) {}
@ -502,8 +502,10 @@ export class ChannelBridge {
const buffered: Array<{ turnId: string; settled: Settled }> = [];
let waiter: { turnId: string; resolve: (settled: Settled) => void } | null = null;
let cancelTimer: (() => void) | null = null;
const unsubscribe = this.deps.sessionBus.subscribe((event) => {
if (event.kind !== "turn-event") return;
// The turn event spine carries every turn's events (deltas included
// for subscribed windows, but settleOf ignores those); subscribeAll
// because the turnId is unknown until sendMessage returns.
const unsubscribe = this.deps.turnEventBus.subscribeAll((event) => {
const settled = settleOf(event.event);
if (!settled) return;
if (waiter) {

Some files were not shown because too many files have changed in this diff Show more