diff --git a/.gitignore b/.gitignore index 2480e5e1..086ea0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/ data/ .venv/ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index db51cb63..51a11e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,14 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca | Workspace config | `apps/x/pnpm-workspace.yaml` | | Root scripts | `apps/x/package.json` | +## Feature Deep-Dives + +Long-form docs for specific features. Read the relevant file before making changes in that area — it has the full product flow, technical flows, and (where applicable) a catalog of the LLM prompts involved with exact file:line pointers. + +| Feature | Doc | +|---------|-----| +| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` | + ## Common Tasks ### LLM configuration (single provider) diff --git a/README.md b/README.md index 640ee35c..361b87a0 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,18 @@ Rowboat connects to your email and meeting notes, builds a long-lived knowledge You can do things like: - `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph - `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) +- Track a person, company or topic through live notes - Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) - Record voice memos that automatically capture and update key takeaways in the graph Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) +⭐ If you find Rowboat useful, please star the repo. It helps more people find it. ## Demo +[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw) - -[![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I) - -[Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I) +[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw) --- @@ -59,19 +59,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do ### Google setup To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). -### Voice notes -To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: +### Voice input +To enable voice input and voice notes (optional), add a Deepgram API key in `~/.rowboat/config/deepgram.json` + +### Voice output + +To enable voice output (optional), add an ElevenLabs API key in `~/.rowboat/config/elevenlabs.json` + +### Web search + +To use Exa research search (optional), add the Exa API key in `~/.rowboat/config/exa-search.json` + +### External tools + +To enable external tools (optional), you can add any MCP server or use Composio tools by adding an API key in `~/.rowboat/config/composio.json` + +All API key files use the same format: ``` { "apiKey": "" } ``` -### Web search -To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json. - -To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json. - -(same format as above) ## What it does @@ -86,8 +94,10 @@ Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Mark Rowboat builds memory from the work you already do, including: - **Gmail** (email) -- **Granola** (meeting notes) -- **Fireflies** (meeting notes) +- **Google Calendar** +- **Rowboat meeting notes** or **Fireflies** + +It also contains a library of product integrations through Composio.dev ## How it’s different @@ -109,17 +119,15 @@ The result is memory that compounds, rather than retrieval that starts cold ever - **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped - **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) -## Background agents +## Live notes -Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time. +Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note. -Examples: -- Draft email replies in the background (grounded in your past context and commitments) -- Generate a daily voice note each morning (agenda, priorities, upcoming meetings) -- Create recurring project updates from the latest emails/notes -- Keep your knowledge graph up to date as new information comes in +- Track a competitor or market topic across X, Reddit, and the news +- Monitor a person, project, or deal across web or your communications +- Keep a running summary of any subject you care about -You control what runs, when it runs, and what gets written back into your local Markdown vault. +Everything is written back into your local Markdown vault. You control what runs and when. ## Bring your own model diff --git a/apps/docs/docs/img/google-setup/07-enter-credentials.png b/apps/docs/docs/img/google-setup/07-enter-credentials.png new file mode 100644 index 00000000..9ab73334 Binary files /dev/null and b/apps/docs/docs/img/google-setup/07-enter-credentials.png differ diff --git a/apps/x/.claude/launch.json b/apps/x/.claude/launch.json deleted file mode 100644 index 3ba43066..00000000 --- a/apps/x/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "renderer-dev", - "runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite", - "runtimeArgs": ["--port", "5173"], - "port": 5173 - } - ] -} diff --git a/apps/x/TRACKS.md b/apps/x/TRACKS.md new file mode 100644 index 00000000..3caf9e41 --- /dev/null +++ b/apps/x/TRACKS.md @@ -0,0 +1,343 @@ +# Track Blocks + +> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand. + +A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary. + +**Example** (a Chicago-time track refreshed hourly): + +~~~markdown +```track +trackId: chicago-time +instruction: Show the current time in Chicago, IL in 12-hour format. +active: true +schedule: + type: cron + expression: "0 * * * *" +``` + + +2:30 PM, Central Time + +~~~ + +## Table of Contents + +1. [Product Overview](#product-overview) +2. [Architecture at a Glance](#architecture-at-a-glance) +3. [Technical Flows](#technical-flows) +4. [Schema Reference](#schema-reference) +5. [Prompts Catalog](#prompts-catalog) +6. [File Map](#file-map) +7. [Known Follow-ups](#known-follow-ups) + +--- + +## Product Overview + +### Trigger types + +A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track. + +| Trigger | When it fires | How to express it | +|---|---|---| +| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset | +| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` | +| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` | +| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` | +| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` | + +Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals. + +### Creating a track + +Three paths, all produce identical on-disk YAML: + +1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension. +2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`. +3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name. + +### Viewing and managing a track + +The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running. + +Clicking the chip opens the **track modal**, where everything happens: + +- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`). +- **Tabs** — *What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata). +- **Advanced** — expandable raw-YAML editor for power users. +- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region. +- **Footer** — *Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately). + +Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`. + +### What Copilot can do + +- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`). +- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event. +- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`. +- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill. + +### After a run + +- The **target region** (between `` markers) is rewritten by the track-run agent using the `update-track-content` tool. +- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML. +- The chip pulses while running, then displays the latest `lastRunAt`. +- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook. + +--- + +## Architecture at a Glance + +``` +Editor chip (display-only) ──click──► TrackModal (React) + │ + ├──► IPC: track:get / update / + │ replaceYaml / delete / run + │ +Backend (main process) + ├─ Scheduler loop (15 s) ──┐ + ├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent + └─ Copilot tool run-track-block ──┘ │ + ▼ + update-track-content tool + │ + ▼ + target region rewritten on disk +``` + +**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields. + +**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context. + +--- + +## Technical Flows + +### 4.1 Scheduling (cron / window / once) + +- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`). +- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`. +- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed. +- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates). +- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`. + +### 4.2 Event pipeline + +**Producers** — any data source that should feed tracks emits events: + +- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: })`. +- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`. + +**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO. + +**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event: + +1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive). +2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`. +3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below). +4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. +5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/.json`, unlink from `pending/`. + +**Pass 1 routing** (`routing.ts:73+ findCandidates`): + +- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly. +- Filter to `active && instruction && eventMatchCriteria` tracks. +- Batches of `BATCH_SIZE = 20`. +- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file. +- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config. + +**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region. + +### 4.3 Run flow (`triggerTrackUpdate`) + +Module: `packages/core/src/knowledge/track/runner.ts`. + +1. **Concurrency guard** — static `runningTracks: Set` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. +2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`. +3. **Create agent run** — `createRun({ agentId: 'track-run' })`. +4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set. +5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). +6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive. +7. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. +8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`. +9. **Store `lastRunSummary`** via `updateTrackBlock`. +10. **Emit `track_run_complete`** with `summary` or `error`. +11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. + +Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`. + +### 4.4 IPC surface + +| Channel | Caller → handler | Purpose | +|---|---|---| +| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` | +| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` | +| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML | +| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML | +| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region | +| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook | + +Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`. + +### 4.5 Renderer integration + +- **Chip** — `apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save. +- **Modal** — `apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called. +- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state. +- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file. + +### 4.6 Copilot skill integration + +- **Skill content** — `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called. +- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync. +- **Skill registration** — `packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array). +- **Loading trigger** — `packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests. +- **Builtin tools** — `packages/core/src/application/lib/builtin-tools.ts`: + - `update-track-content` — low-level: rewrite the target region between `` markers. Used mainly by the track-run agent. + - `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`). + +### 4.7 Concurrency & FIFO guarantees + +- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC. +- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file. +- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too. +- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point. + +--- + +## Schema Reference + +All canonical schemas live in `packages/shared/src/track-block.ts`: + +- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`. +- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`. +- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`. +- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`. + +Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth. + +--- + +## Prompts Catalog + +Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`). + +### 1. Routing system prompt (Pass 1 classifier) + +- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them. +- **File**: `packages/core/src/knowledge/track/routing.ts:22–37` (`ROUTING_SYSTEM_PROMPT`). +- **Inputs**: none interpolated — constant system prompt. +- **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`. +- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`. + +### 2. Routing user prompt template + +- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt. +- **File**: `packages/core/src/knowledge/track/routing.ts:51–66` (`buildRoutingPrompt`). +- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`). +- **Output**: plain text, two sections — `## Event` and `## Track Blocks`. +- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below). + +### 3. Track-run agent instructions + +- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path. +- **File**: `packages/core/src/knowledge/track/run-agent.ts:6–50` (`TRACK_RUN_INSTRUCTIONS`). +- **Inputs**: `${WorkDir}` template literal (substituted at module load). +- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`. +- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`. + +### 4. Track-run agent message (`buildMessage`) + +- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`. +- **File**: `packages/core/src/knowledge/track/runner.ts:23–62`. +- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`. +- **Output**: free-form — the agent decides whether to call `update-track-content`. + +Three branches: + +- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills. +- **`timed`** — same as `manual`. Called by the scheduler with no `context`. +- **`event`** — adds a **Pass 2 decision block** (lines 45–56). Quoted verbatim: + + > **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below) + > + > **Event match criteria for this track:** … + > + > **Event payload:** … + > + > **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track. + +### 5. Tracks skill (Copilot-facing) + +- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context. +- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant. +- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically. +- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires. +- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`. +- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template. + +### 6. Copilot trigger paragraph + +- **Purpose**: tells Copilot *when* to load the `tracks` skill. +- **File**: `packages/core/src/application/assistant/instructions.ts:73`. +- **Inputs**: none; static prose. +- **Output**: part of the baseline Copilot system prompt. +- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh. + +### 7. `run-track-block` tool — `context` parameter description + +- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema. +- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt). +- **Inputs**: free-form string from Copilot. +- **Output**: flows into `triggerTrackUpdate(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message. +- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. + +### 8. Calendar sync digest (event payload template) + +- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`. +- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126. +- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync. +- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars. +- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look. + +--- + +## File Map + +| Purpose | File | +|---|---| +| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` | +| IPC channel schemas | `packages/shared/src/ipc.ts` | +| IPC handlers (main process) | `apps/main/src/ipc.ts` | +| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` | +| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` | +| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` | +| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` | +| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` | +| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` | +| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` | +| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` | +| Track state type | `packages/core/src/knowledge/track/types.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/tracks/skill.ts` | +| Skill registration | `packages/core/src/application/assistant/skills/index.ts` | +| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | +| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` | +| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` | +| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` | +| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` | +| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` | +| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` | +| Main process startup (schedulers & processors) | `apps/main/src/main.ts` | + +--- + +## Known Follow-ups + +- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields. + - **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save. + - **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor). + +- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow. diff --git a/apps/x/apps/main/entitlements.plist b/apps/x/apps/main/entitlements.plist index db2dbd7e..c0899b9d 100644 --- a/apps/x/apps/main/entitlements.plist +++ b/apps/x/apps/main/entitlements.plist @@ -2,6 +2,8 @@ + com.apple.security.cs.allow-jit + com.apple.security.device.audio-input com.apple.security.device.screen-capture diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index c79a8c43..178cb7e1 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,6 +11,9 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + extendInfo: { + NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', + }, osxSign: { batchCodesignCalls: true, optionsForFile: () => ({ diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index b0b890c0..ad184451 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -22,10 +22,11 @@ export interface AuthServerResult { /** * Create a local HTTP server to handle OAuth callback * Listens on http://localhost:8080/oauth/callback + * Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds. */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void | Promise + onCallback: (callbackUrl: URL) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -38,8 +39,6 @@ export function createAuthServer( const url = new URL(req.url, `http://localhost:${port}`); if (url.pathname === OAUTH_CALLBACK_PATH) { - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { @@ -65,9 +64,8 @@ export function createAuthServer( return; } - // Handle callback - either traditional OAuth with code/state or Composio-style notification - // Composio callbacks may not have code/state, just a notification that the flow completed - onCallback(code || '', state || ''); + // Handle callback - pass full URL so params like iss (OpenID Connect) are preserved for token exchange + onCallback(url); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` diff --git a/apps/x/apps/main/src/browser/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts new file mode 100644 index 00000000..b83ea7cb --- /dev/null +++ b/apps/x/apps/main/src/browser/control-service.ts @@ -0,0 +1,243 @@ +import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js'; +import type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js'; +import { browserViewManager } from './view.js'; +import { normalizeNavigationTarget } from './navigation.js'; + +function buildSuccessResult( + action: BrowserControlAction, + message: string, + page?: BrowserControlResult['page'], +): BrowserControlResult { + return { + success: true, + action, + message, + browser: browserViewManager.getState(), + ...(page ? { page } : {}), + }; +} + +function buildErrorResult(action: BrowserControlAction, error: string): BrowserControlResult { + return { + success: false, + action, + error, + browser: browserViewManager.getState(), + }; +} + +export class ElectronBrowserControlService implements IBrowserControlService { + async execute( + input: BrowserControlInput, + ctx?: { signal?: AbortSignal }, + ): Promise { + const signal = ctx?.signal; + + try { + switch (input.action) { + case 'open': { + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('open', 'Opened a browser session.', page); + } + + case 'get-state': + return buildSuccessResult('get-state', 'Read the current browser state.'); + + case 'new-tab': { + const target = input.target ? normalizeNavigationTarget(input.target) : undefined; + const result = await browserViewManager.newTab(target); + if (!result.ok) { + return buildErrorResult('new-tab', result.error ?? 'Failed to open a new tab.'); + } + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult( + 'new-tab', + target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', + page, + ); + } + + case 'switch-tab': { + const tabId = input.tabId; + if (!tabId) { + return buildErrorResult('switch-tab', 'tabId is required for switch-tab.'); + } + const result = browserViewManager.switchTab(tabId); + if (!result.ok) { + return buildErrorResult('switch-tab', `No browser tab exists with id ${tabId}.`); + } + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('switch-tab', `Switched to tab ${tabId}.`, page); + } + + case 'close-tab': { + const tabId = input.tabId; + if (!tabId) { + return buildErrorResult('close-tab', 'tabId is required for close-tab.'); + } + const result = browserViewManager.closeTab(tabId); + if (!result.ok) { + return buildErrorResult('close-tab', `Could not close tab ${tabId}.`); + } + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('close-tab', `Closed tab ${tabId}.`, page); + } + + case 'navigate': { + const rawTarget = input.target; + if (!rawTarget) { + return buildErrorResult('navigate', 'target is required for navigate.'); + } + const target = normalizeNavigationTarget(rawTarget); + const result = await browserViewManager.navigate(target); + if (!result.ok) { + return buildErrorResult('navigate', result.error ?? `Failed to navigate to ${target}.`); + } + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('navigate', `Navigated to ${target}.`, page); + } + + case 'back': { + const result = browserViewManager.back(); + if (!result.ok) { + return buildErrorResult('back', 'The active tab cannot go back.'); + } + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('back', 'Went back in the active tab.', page); + } + + case 'forward': { + const result = browserViewManager.forward(); + if (!result.ok) { + return buildErrorResult('forward', 'The active tab cannot go forward.'); + } + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('forward', 'Went forward in the active tab.', page); + } + + case 'reload': { + browserViewManager.reload(); + await browserViewManager.ensureActiveTabReady(signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('reload', 'Reloaded the active tab.', page); + } + + case 'read-page': { + const result = await browserViewManager.readPage( + { + maxElements: input.maxElements, + maxTextLength: input.maxTextLength, + }, + signal, + ); + if (!result.ok || !result.page) { + return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.'); + } + return buildSuccessResult('read-page', 'Read the current page.', result.page); + } + + case 'click': { + const result = await browserViewManager.click( + { + index: input.index, + selector: input.selector, + snapshotId: input.snapshotId, + }, + signal, + ); + if (!result.ok) { + return buildErrorResult('click', result.error ?? 'Failed to click the requested element.'); + } + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult( + 'click', + result.description ? `Clicked ${result.description}.` : 'Clicked the requested element.', + page, + ); + } + + case 'type': { + const text = input.text; + if (text === undefined) { + return buildErrorResult('type', 'text is required for type.'); + } + const result = await browserViewManager.type( + { + index: input.index, + selector: input.selector, + snapshotId: input.snapshotId, + }, + text, + signal, + ); + if (!result.ok) { + return buildErrorResult('type', result.error ?? 'Failed to type into the requested element.'); + } + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult( + 'type', + result.description ? `Typed into ${result.description}.` : 'Typed into the requested element.', + page, + ); + } + + case 'press': { + const key = input.key; + if (!key) { + return buildErrorResult('press', 'key is required for press.'); + } + const result = await browserViewManager.press( + key, + { + index: input.index, + selector: input.selector, + snapshotId: input.snapshotId, + }, + signal, + ); + if (!result.ok) { + return buildErrorResult('press', result.error ?? `Failed to press ${key}.`); + } + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult( + 'press', + result.description ? `Pressed ${result.description}.` : `Pressed ${key}.`, + page, + ); + } + + case 'scroll': { + const result = await browserViewManager.scroll( + input.direction ?? 'down', + input.amount ?? 700, + signal, + ); + if (!result.ok) { + return buildErrorResult('scroll', result.error ?? 'Failed to scroll the page.'); + } + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('scroll', `Scrolled ${input.direction ?? 'down'}.`, page); + } + + case 'wait': { + const duration = input.ms ?? 1000; + await browserViewManager.wait(duration, signal); + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; + return buildSuccessResult('wait', `Waited ${duration}ms for the page to settle.`, page); + } + } + } catch (error) { + return buildErrorResult( + input.action, + error instanceof Error ? error.message : 'Browser control failed unexpectedly.', + ); + } + } +} diff --git a/apps/x/apps/main/src/browser/ipc.ts b/apps/x/apps/main/src/browser/ipc.ts new file mode 100644 index 00000000..fa3b1ac1 --- /dev/null +++ b/apps/x/apps/main/src/browser/ipc.ts @@ -0,0 +1,81 @@ +import { BrowserWindow } from 'electron'; +import { ipc } from '@x/shared'; +import { browserViewManager, type BrowserState } from './view.js'; + +type IPCChannels = ipc.IPCChannels; + +type InvokeHandler = ( + event: Electron.IpcMainInvokeEvent, + args: IPCChannels[K]['req'], +) => IPCChannels[K]['res'] | Promise; + +type BrowserHandlers = { + 'browser:setBounds': InvokeHandler<'browser:setBounds'>; + 'browser:setVisible': InvokeHandler<'browser:setVisible'>; + 'browser:newTab': InvokeHandler<'browser:newTab'>; + 'browser:switchTab': InvokeHandler<'browser:switchTab'>; + 'browser:closeTab': InvokeHandler<'browser:closeTab'>; + 'browser:navigate': InvokeHandler<'browser:navigate'>; + 'browser:back': InvokeHandler<'browser:back'>; + 'browser:forward': InvokeHandler<'browser:forward'>; + 'browser:reload': InvokeHandler<'browser:reload'>; + 'browser:getState': InvokeHandler<'browser:getState'>; +}; + +/** + * Browser-specific IPC handlers, exported as a plain object so they can be + * spread into the main `registerIpcHandlers({...})` call in ipc.ts. This + * mirrors the convention of keeping feature handlers flat and namespaced by + * channel prefix (`browser:*`). + */ +export const browserIpcHandlers: BrowserHandlers = { + 'browser:setBounds': async (_event, args) => { + browserViewManager.setBounds(args); + return { ok: true }; + }, + 'browser:setVisible': async (_event, args) => { + browserViewManager.setVisible(args.visible); + return { ok: true }; + }, + 'browser:newTab': async (_event, args) => { + return browserViewManager.newTab(args.url); + }, + 'browser:switchTab': async (_event, args) => { + return browserViewManager.switchTab(args.tabId); + }, + 'browser:closeTab': async (_event, args) => { + return browserViewManager.closeTab(args.tabId); + }, + 'browser:navigate': async (_event, args) => { + return browserViewManager.navigate(args.url); + }, + 'browser:back': async () => { + return browserViewManager.back(); + }, + 'browser:forward': async () => { + return browserViewManager.forward(); + }, + 'browser:reload': async () => { + browserViewManager.reload(); + return { ok: true }; + }, + 'browser:getState': async () => { + return browserViewManager.getState(); + }, +}; + +/** + * Wire the BrowserViewManager's state-updated event to all renderer windows + * as a `browser:didUpdateState` push. Must be called once after the main + * window is created so the manager has a window to attach to. + */ +export function setupBrowserEventForwarding(): void { + browserViewManager.on('state-updated', (state: BrowserState) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('browser:didUpdateState', state); + } + } + }); +} diff --git a/apps/x/apps/main/src/browser/navigation.ts b/apps/x/apps/main/src/browser/navigation.ts new file mode 100644 index 00000000..ac840956 --- /dev/null +++ b/apps/x/apps/main/src/browser/navigation.ts @@ -0,0 +1,41 @@ +const SEARCH_ENGINE_BASE_URL = 'https://www.google.com/search?q='; + +const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const IPV4_HOST_RE = /^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/.*)?$/; +const LOCALHOST_RE = /^localhost(?::\d+)?(?:\/.*)?$/i; +const DOMAIN_LIKE_RE = /^[\w.-]+\.[a-z]{2,}(?::\d+)?(?:\/.*)?$/i; + +export function normalizeNavigationTarget(target: string): string { + const trimmed = target.trim(); + if (!trimmed) { + throw new Error('Navigation target cannot be empty.'); + } + + const lower = trimmed.toLowerCase(); + if ( + lower.startsWith('javascript:') + || lower.startsWith('file://') + || lower.startsWith('chrome://') + || lower.startsWith('chrome-extension://') + ) { + throw new Error('That URL scheme is not allowed in the embedded browser.'); + } + + if (HAS_SCHEME_RE.test(trimmed)) { + return trimmed; + } + + const looksLikeHost = + LOCALHOST_RE.test(trimmed) + || DOMAIN_LIKE_RE.test(trimmed) + || IPV4_HOST_RE.test(trimmed); + + if (looksLikeHost && !/\s/.test(trimmed)) { + const scheme = LOCALHOST_RE.test(trimmed) || IPV4_HOST_RE.test(trimmed) + ? 'http://' + : 'https://'; + return `${scheme}${trimmed}`; + } + + return `${SEARCH_ENGINE_BASE_URL}${encodeURIComponent(trimmed)}`; +} diff --git a/apps/x/apps/main/src/browser/page-scripts.ts b/apps/x/apps/main/src/browser/page-scripts.ts new file mode 100644 index 00000000..fc079327 --- /dev/null +++ b/apps/x/apps/main/src/browser/page-scripts.ts @@ -0,0 +1,546 @@ +import type { BrowserPageElement } from '@x/shared/dist/browser-control.js'; + +const INTERACTABLE_SELECTORS = [ + 'a[href]', + 'button', + 'input', + 'textarea', + 'select', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="tab"]', + '[role="menuitem"]', + '[role="option"]', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(', '); + +const CLICKABLE_TARGET_SELECTORS = [ + 'a[href]', + 'button', + 'summary', + 'label', + 'input', + 'textarea', + 'select', + '[role="button"]', + '[role="link"]', + '[role="tab"]', + '[role="menuitem"]', + '[role="option"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[aria-pressed]', + '[aria-expanded]', + '[aria-checked]', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(', '); + +const DOM_HELPERS_SOURCE = String.raw` +const truncateText = (value, max) => { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!normalized) return ''; + if (normalized.length <= max) return normalized; + const safeMax = Math.max(0, max - 3); + return normalized.slice(0, safeMax).trim() + '...'; +}; + +const cssEscapeValue = (value) => { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\' + char); +}; + +const isVisibleElement = (element) => { + if (!(element instanceof Element)) return false; + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + if (element.getAttribute('aria-hidden') === 'true') return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +}; + +const isDisabledElement = (element) => { + if (!(element instanceof Element)) return true; + if (element.getAttribute('aria-disabled') === 'true') return true; + return 'disabled' in element && Boolean(element.disabled); +}; + +const isUselessClickTarget = (element) => ( + element === document.body + || element === document.documentElement +); + +const getElementRole = (element) => { + const explicitRole = element.getAttribute('role'); + if (explicitRole) return explicitRole; + if (element instanceof HTMLAnchorElement) return 'link'; + if (element instanceof HTMLButtonElement) return 'button'; + if (element instanceof HTMLInputElement) return element.type === 'checkbox' ? 'checkbox' : 'input'; + if (element instanceof HTMLTextAreaElement) return 'textbox'; + if (element instanceof HTMLSelectElement) return 'combobox'; + if (element instanceof HTMLElement && element.isContentEditable) return 'textbox'; + return null; +}; + +const getElementType = (element) => { + if (element instanceof HTMLInputElement) return element.type || 'text'; + if (element instanceof HTMLTextAreaElement) return 'textarea'; + if (element instanceof HTMLSelectElement) return 'select'; + if (element instanceof HTMLButtonElement) return 'button'; + if (element instanceof HTMLElement && element.isContentEditable) return 'contenteditable'; + return null; +}; + +const getElementLabel = (element) => { + const ariaLabel = truncateText(element.getAttribute('aria-label') ?? '', 120); + if (ariaLabel) return ariaLabel; + + if ('labels' in element && element.labels && element.labels.length > 0) { + const labelText = truncateText( + Array.from(element.labels).map((label) => label.innerText || label.textContent || '').join(' '), + 120, + ); + if (labelText) return labelText; + } + + if (element.id) { + const label = document.querySelector('label[for="' + cssEscapeValue(element.id) + '"]'); + const labelText = truncateText(label?.textContent ?? '', 120); + if (labelText) return labelText; + } + + const placeholder = truncateText(element.getAttribute('placeholder') ?? '', 120); + if (placeholder) return placeholder; + + const text = truncateText( + element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement + ? element.value + : element.textContent ?? '', + 120, + ); + return text || null; +}; + +const describeElement = (element) => { + const role = getElementRole(element) || element.tagName.toLowerCase(); + const label = getElementLabel(element); + return label ? role + ' "' + label + '"' : role; +}; + +const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max); + +const getAssociatedControl = (element) => { + if (!(element instanceof Element)) return null; + if (element instanceof HTMLLabelElement) return element.control; + const parentLabel = element.closest('label'); + return parentLabel instanceof HTMLLabelElement ? parentLabel.control : null; +}; + +const resolveClickTarget = (element) => { + if (!(element instanceof Element)) return null; + + const clickableAncestor = element.closest(${JSON.stringify(CLICKABLE_TARGET_SELECTORS)}); + const labelAncestor = element.closest('label'); + const associatedControl = getAssociatedControl(element); + const candidates = [clickableAncestor, labelAncestor, associatedControl, element]; + + for (const candidate of candidates) { + if (!(candidate instanceof Element)) continue; + if (isUselessClickTarget(candidate)) continue; + if (!isVisibleElement(candidate)) continue; + if (isDisabledElement(candidate)) continue; + return candidate; + } + + for (const candidate of candidates) { + if (candidate instanceof Element) return candidate; + } + + return null; +}; + +const getVerificationTargetState = (element) => { + if (!(element instanceof Element)) return null; + + const text = truncateText(element.innerText || element.textContent || '', 200); + const activeElement = document.activeElement; + const isActive = + activeElement instanceof Element + ? activeElement === element || element.contains(activeElement) + : false; + + return { + selector: buildUniqueSelector(element), + descriptor: describeElement(element), + text: text || null, + checked: + element instanceof HTMLInputElement && (element.type === 'checkbox' || element.type === 'radio') + ? element.checked + : null, + value: + element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement + ? truncateText(element.value ?? '', 200) + : element instanceof HTMLSelectElement + ? truncateText(element.value ?? '', 200) + : element instanceof HTMLElement && element.isContentEditable + ? truncateText(element.innerText || element.textContent || '', 200) + : null, + selectedIndex: element instanceof HTMLSelectElement ? element.selectedIndex : null, + open: + 'open' in element && typeof element.open === 'boolean' + ? element.open + : null, + disabled: isDisabledElement(element), + active: isActive, + ariaChecked: element.getAttribute('aria-checked'), + ariaPressed: element.getAttribute('aria-pressed'), + ariaExpanded: element.getAttribute('aria-expanded'), + }; +}; + +const getPageVerificationState = () => { + const activeElement = document.activeElement instanceof Element ? document.activeElement : null; + return { + url: window.location.href, + title: document.title || '', + textSample: truncateText(document.body?.innerText || document.body?.textContent || '', 2000), + activeSelector: activeElement ? buildUniqueSelector(activeElement) : null, + }; +}; + +const buildUniqueSelector = (element) => { + if (!(element instanceof Element)) return null; + + if (element.id) { + const idSelector = '#' + cssEscapeValue(element.id); + try { + if (document.querySelectorAll(idSelector).length === 1) return idSelector; + } catch {} + } + + const segments = []; + let current = element; + while (current && current instanceof Element && current !== document.documentElement) { + const tag = current.tagName.toLowerCase(); + if (!tag) break; + + let segment = tag; + const name = current.getAttribute('name'); + if (name) { + const nameSelector = tag + '[name="' + cssEscapeValue(name) + '"]'; + try { + if (document.querySelectorAll(nameSelector).length === 1) { + segments.unshift(nameSelector); + return segments.join(' > '); + } + } catch {} + } + + const parent = current.parentElement; + if (parent) { + const sameTagSiblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); + const position = sameTagSiblings.indexOf(current) + 1; + segment += ':nth-of-type(' + position + ')'; + } + + segments.unshift(segment); + const selector = segments.join(' > '); + try { + if (document.querySelectorAll(selector).length === 1) return selector; + } catch {} + + current = current.parentElement; + } + + return segments.length > 0 ? segments.join(' > ') : null; +}; +`; + +type RawBrowserPageElement = BrowserPageElement & { + selector: string; +}; + +export type RawBrowserPageSnapshot = { + url: string; + title: string; + loading: boolean; + text: string; + elements: RawBrowserPageElement[]; +}; + +export type ElementTarget = { + index?: number; + selector?: string; + snapshotId?: string; +}; + +export function buildReadPageScript(maxElements: number, maxTextLength: number): string { + return `(() => { + ${DOM_HELPERS_SOURCE} + const candidates = Array.from(document.querySelectorAll(${JSON.stringify(INTERACTABLE_SELECTORS)})); + const elements = []; + const seenSelectors = new Set(); + + for (const candidate of candidates) { + if (!(candidate instanceof Element)) continue; + if (!isVisibleElement(candidate)) continue; + + const selector = buildUniqueSelector(candidate); + if (!selector || seenSelectors.has(selector)) continue; + seenSelectors.add(selector); + + elements.push({ + index: elements.length + 1, + selector, + tagName: candidate.tagName.toLowerCase(), + role: getElementRole(candidate), + type: getElementType(candidate), + label: getElementLabel(candidate), + text: truncateText(candidate.innerText || candidate.textContent || '', 120) || null, + placeholder: truncateText(candidate.getAttribute('placeholder') ?? '', 120) || null, + href: candidate instanceof HTMLAnchorElement ? candidate.href : candidate.getAttribute('href'), + disabled: isDisabledElement(candidate), + }); + + if (elements.length >= ${JSON.stringify(maxElements)}) break; + } + + return { + url: window.location.href, + title: document.title || '', + loading: document.readyState !== 'complete', + text: truncateText(document.body?.innerText || document.body?.textContent || '', ${JSON.stringify(maxTextLength)}), + elements, + }; + })()`; +} + +export function buildClickScript(selector: string): string { + return `(() => { + ${DOM_HELPERS_SOURCE} + const requestedSelector = ${JSON.stringify(selector)}; + if (/^(body|html)$/i.test(requestedSelector.trim())) { + return { + ok: false, + error: 'Refusing to click the page body. Read the page again and target a specific element.', + }; + } + + const element = document.querySelector(requestedSelector); + if (!(element instanceof Element)) { + return { ok: false, error: 'Element not found.' }; + } + if (isUselessClickTarget(element)) { + return { + ok: false, + error: 'Refusing to click the page body. Read the page again and target a specific element.', + }; + } + + const target = resolveClickTarget(element); + if (!(target instanceof Element)) { + return { ok: false, error: 'Could not resolve a clickable target.' }; + } + if (isUselessClickTarget(target)) { + return { + ok: false, + error: 'Resolved click target was too generic. Read the page again and choose a specific control.', + }; + } + if (!isVisibleElement(target)) { + return { ok: false, error: 'Resolved click target is not visible.' }; + } + if (isDisabledElement(target)) { + return { ok: false, error: 'Resolved click target is disabled.' }; + } + + const before = { + page: getPageVerificationState(), + target: getVerificationTargetState(target), + }; + + if (target instanceof HTMLElement) { + target.scrollIntoView({ block: 'center', inline: 'center' }); + target.focus({ preventScroll: true }); + } + + const rect = target.getBoundingClientRect(); + const clientX = clampNumber(rect.left + (rect.width / 2), 1, Math.max(1, window.innerWidth - 1)); + const clientY = clampNumber(rect.top + (rect.height / 2), 1, Math.max(1, window.innerHeight - 1)); + const topElement = document.elementFromPoint(clientX, clientY); + const eventTarget = + topElement instanceof Element && (topElement === target || topElement.contains(target) || target.contains(topElement)) + ? topElement + : target; + + if (eventTarget instanceof HTMLElement) { + eventTarget.focus({ preventScroll: true }); + } + + return { + ok: true, + description: describeElement(target), + clickPoint: { + x: Math.round(clientX), + y: Math.round(clientY), + }, + verification: { + before, + targetSelector: buildUniqueSelector(target) || requestedSelector, + }, + }; + })()`; +} + +export function buildVerifyClickScript(targetSelector: string | null, before: unknown): string { + return `(() => { + ${DOM_HELPERS_SOURCE} + const beforeState = ${JSON.stringify(before)}; + const selector = ${JSON.stringify(targetSelector)}; + const afterPage = getPageVerificationState(); + const afterTarget = selector ? getVerificationTargetState(document.querySelector(selector)) : null; + const beforeTarget = beforeState?.target ?? null; + const reasons = []; + + if (beforeState?.page?.url !== afterPage.url) reasons.push('url changed'); + if (beforeState?.page?.title !== afterPage.title) reasons.push('title changed'); + if (beforeState?.page?.textSample !== afterPage.textSample) reasons.push('page text changed'); + if (beforeState?.page?.activeSelector !== afterPage.activeSelector) reasons.push('focus changed'); + + if (beforeTarget && !afterTarget) { + reasons.push('clicked element disappeared'); + } + + if (beforeTarget && afterTarget) { + if (beforeTarget.checked !== afterTarget.checked) reasons.push('checked state changed'); + if (beforeTarget.value !== afterTarget.value) reasons.push('value changed'); + if (beforeTarget.selectedIndex !== afterTarget.selectedIndex) reasons.push('selection changed'); + if (beforeTarget.open !== afterTarget.open) reasons.push('open state changed'); + if (beforeTarget.disabled !== afterTarget.disabled) reasons.push('disabled state changed'); + if (beforeTarget.active !== afterTarget.active) reasons.push('target focus changed'); + if (beforeTarget.ariaChecked !== afterTarget.ariaChecked) reasons.push('aria-checked changed'); + if (beforeTarget.ariaPressed !== afterTarget.ariaPressed) reasons.push('aria-pressed changed'); + if (beforeTarget.ariaExpanded !== afterTarget.ariaExpanded) reasons.push('aria-expanded changed'); + if (beforeTarget.text !== afterTarget.text) reasons.push('target text changed'); + } + + return { + changed: reasons.length > 0, + reasons, + }; + })()`; +} + +export function buildTypeScript(selector: string, text: string): string { + return `(() => { + ${DOM_HELPERS_SOURCE} + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof Element)) { + return { ok: false, error: 'Element not found.' }; + } + if (!isVisibleElement(element)) { + return { ok: false, error: 'Element is not visible.' }; + } + if (isDisabledElement(element)) { + return { ok: false, error: 'Element is disabled.' }; + } + + const nextValue = ${JSON.stringify(text)}; + + const setNativeValue = (target, value) => { + const prototype = Object.getPrototypeOf(target); + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + if (descriptor && typeof descriptor.set === 'function') { + descriptor.set.call(target, value); + } else { + target.value = value; + } + }; + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.readOnly) { + return { ok: false, error: 'Element is read-only.' }; + } + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.focus({ preventScroll: true }); + setNativeValue(element, nextValue); + element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' })); + element.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true, description: describeElement(element) }; + } + + if (element instanceof HTMLElement && element.isContentEditable) { + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.focus({ preventScroll: true }); + element.textContent = nextValue; + element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' })); + return { ok: true, description: describeElement(element) }; + } + + return { ok: false, error: 'Element does not accept text input.' }; + })()`; +} + +export function buildFocusScript(selector: string): string { + return `(() => { + ${DOM_HELPERS_SOURCE} + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof Element)) { + return { ok: false, error: 'Element not found.' }; + } + if (!isVisibleElement(element)) { + return { ok: false, error: 'Element is not visible.' }; + } + if (element instanceof HTMLElement) { + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.focus({ preventScroll: true }); + } + return { ok: true, description: describeElement(element) }; + })()`; +} + +export function buildScrollScript(offset: number): string { + return `(() => { + window.scrollBy({ top: ${JSON.stringify(offset)}, left: 0, behavior: 'auto' }); + return { ok: true }; + })()`; +} + +export function normalizeKeyCode(key: string): string { + const trimmed = key.trim(); + if (!trimmed) return 'Enter'; + + const aliases: Record = { + esc: 'Escape', + escape: 'Escape', + return: 'Enter', + enter: 'Enter', + tab: 'Tab', + space: 'Space', + ' ': 'Space', + left: 'ArrowLeft', + right: 'ArrowRight', + up: 'ArrowUp', + down: 'ArrowDown', + arrowleft: 'ArrowLeft', + arrowright: 'ArrowRight', + arrowup: 'ArrowUp', + arrowdown: 'ArrowDown', + backspace: 'Backspace', + delete: 'Delete', + }; + + const alias = aliases[trimmed.toLowerCase()]; + if (alias) return alias; + if (trimmed.length === 1) return trimmed.toUpperCase(); + return trimmed[0].toUpperCase() + trimmed.slice(1); +} diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts new file mode 100644 index 00000000..d319c5fb --- /dev/null +++ b/apps/x/apps/main/src/browser/view.ts @@ -0,0 +1,797 @@ +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron'; +import type { + BrowserPageElement, + BrowserPageSnapshot, + BrowserState, + BrowserTabState, +} from '@x/shared/dist/browser-control.js'; +import { normalizeNavigationTarget } from './navigation.js'; +import { + buildClickScript, + buildFocusScript, + buildReadPageScript, + buildScrollScript, + buildTypeScript, + buildVerifyClickScript, + normalizeKeyCode, + type ElementTarget, + type RawBrowserPageSnapshot, +} from './page-scripts.js'; + +export type { BrowserPageSnapshot, BrowserState, BrowserTabState }; + +/** + * Embedded browser pane implementation. + * + * Each browser tab owns its own WebContentsView. Only the active tab's view is + * attached to the main window at a time, but inactive tabs keep their own page + * history and loaded state in memory so switching tabs feels immediate. + * + * All tabs share one persistent session partition so cookies/localStorage/ + * form-fill state survive app restarts, and the browser surface spoofs a + * standard Chrome UA so sites like Google (OAuth) don't reject it. + */ + +export const BROWSER_PARTITION = 'persist:rowboat-browser'; + +// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers +// that sniff the UA looking for "real browser" shapes. +const SPOOF_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; + +const HOME_URL = 'https://www.google.com'; +const NAVIGATION_TIMEOUT_MS = 10000; +const POST_ACTION_IDLE_MS = 400; +const POST_ACTION_MAX_ELEMENTS = 25; +const POST_ACTION_MAX_TEXT_LENGTH = 4000; +const DEFAULT_READ_MAX_ELEMENTS = 50; +const DEFAULT_READ_MAX_TEXT_LENGTH = 8000; + +export interface BrowserBounds { + x: number; + y: number; + width: number; + height: number; +} + +type BrowserTab = { + id: string; + view: WebContentsView; + domReadyAt: number | null; + loadError: string | null; +}; + +type CachedSnapshot = { + snapshotId: string; + elements: Array<{ index: number; selector: string }>; +}; + +const EMPTY_STATE: BrowserState = { + activeTabId: null, + tabs: [], +}; + +function abortIfNeeded(signal?: AbortSignal): void { + if (!signal?.aborted) return; + throw signal.reason instanceof Error ? signal.reason : new Error('Browser action aborted'); +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return; + abortIfNeeded(signal); + await new Promise((resolve, reject) => { + const abortSignal = signal; + const timer = setTimeout(() => { + abortSignal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timer); + abortSignal?.removeEventListener('abort', onAbort); + reject(abortSignal?.reason instanceof Error ? abortSignal.reason : new Error('Browser action aborted')); + }; + + abortSignal?.addEventListener('abort', onAbort, { once: true }); + }); +} + + +export class BrowserViewManager extends EventEmitter { + private window: BrowserWindow | null = null; + private browserSession: Session | null = null; + private tabs = new Map(); + private tabOrder: string[] = []; + private activeTabId: string | null = null; + private attachedTabId: string | null = null; + private visible = false; + private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; + private snapshotCache = new Map(); + + attach(window: BrowserWindow): void { + this.window = window; + window.on('closed', () => { + this.window = null; + this.browserSession = null; + this.tabs.clear(); + this.tabOrder = []; + this.activeTabId = null; + this.attachedTabId = null; + this.visible = false; + this.snapshotCache.clear(); + }); + } + + private getSession(): Session { + if (this.browserSession) return this.browserSession; + const browserSession = session.fromPartition(BROWSER_PARTITION); + browserSession.setUserAgent(SPOOF_UA); + this.browserSession = browserSession; + return browserSession; + } + + private emitState(): void { + this.emit('state-updated', this.snapshotState()); + } + + private getTab(tabId: string | null): BrowserTab | null { + if (!tabId) return null; + return this.tabs.get(tabId) ?? null; + } + + private getActiveTab(): BrowserTab | null { + return this.getTab(this.activeTabId); + } + + private invalidateSnapshot(tabId: string): void { + this.snapshotCache.delete(tabId); + } + + private isEmbeddedTabUrl(url: string): boolean { + return /^https?:\/\//i.test(url) || url === 'about:blank'; + } + + private createView(): WebContentsView { + const view = new WebContentsView({ + webPreferences: { + session: this.getSession(), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + }, + }); + + view.webContents.setUserAgent(SPOOF_UA); + return view; + } + + private wireEvents(tab: BrowserTab): void { + const { id: tabId, view } = tab; + const wc = view.webContents; + + const reapplyBounds = () => { + if ( + this.attachedTabId === tabId && + this.visible && + this.bounds.width > 0 && + this.bounds.height > 0 + ) { + view.setBounds(this.bounds); + } + }; + + const invalidateAndEmit = () => { + this.invalidateSnapshot(tabId); + this.emitState(); + }; + + wc.on('did-start-navigation', (_event, _url, _isInPlace, isMainFrame) => { + if (isMainFrame !== false) { + tab.domReadyAt = null; + tab.loadError = null; + } + this.invalidateSnapshot(tabId); + reapplyBounds(); + }); + wc.on('did-navigate', () => { reapplyBounds(); invalidateAndEmit(); }); + wc.on('did-navigate-in-page', () => { reapplyBounds(); invalidateAndEmit(); }); + wc.on('did-start-loading', () => { + tab.loadError = null; + this.invalidateSnapshot(tabId); + reapplyBounds(); + this.emitState(); + }); + wc.on('did-stop-loading', () => { reapplyBounds(); invalidateAndEmit(); }); + wc.on('did-finish-load', () => { reapplyBounds(); invalidateAndEmit(); }); + wc.on('dom-ready', () => { + tab.domReadyAt = Date.now(); + reapplyBounds(); + invalidateAndEmit(); + }); + wc.on('did-frame-finish-load', reapplyBounds); + wc.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (isMainFrame && errorCode !== -3) { + const target = validatedURL || wc.getURL() || 'page'; + tab.loadError = errorDescription + ? `Failed to load ${target}: ${errorDescription}.` + : `Failed to load ${target}.`; + } + reapplyBounds(); + invalidateAndEmit(); + }); + wc.on('page-title-updated', this.emitState.bind(this)); + + wc.setWindowOpenHandler(({ url }) => { + if (this.isEmbeddedTabUrl(url)) { + void this.newTab(url); + } else { + void shell.openExternal(url); + } + return { action: 'deny' }; + }); + } + + private snapshotTabState(tab: BrowserTab): BrowserTabState { + const wc = tab.view.webContents; + return { + id: tab.id, + url: wc.getURL(), + title: wc.getTitle(), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + loading: wc.isLoading(), + }; + } + + private syncAttachedView(): void { + if (!this.window) return; + + const contentView = this.window.contentView; + const activeTab = this.getActiveTab(); + + if (!this.visible || !activeTab) { + const attachedTab = this.getTab(this.attachedTabId); + if (attachedTab) { + contentView.removeChildView(attachedTab.view); + } + this.attachedTabId = null; + return; + } + + if (this.attachedTabId && this.attachedTabId !== activeTab.id) { + const attachedTab = this.getTab(this.attachedTabId); + if (attachedTab) { + contentView.removeChildView(attachedTab.view); + } + this.attachedTabId = null; + } + + if (this.attachedTabId !== activeTab.id) { + contentView.addChildView(activeTab.view); + this.attachedTabId = activeTab.id; + } + + if (this.bounds.width > 0 && this.bounds.height > 0) { + activeTab.view.setBounds(this.bounds); + } + } + + private createTab(initialUrl: string): BrowserTab { + if (!this.window) { + throw new Error('BrowserViewManager: no window attached'); + } + + const tabId = randomUUID(); + const tab: BrowserTab = { + id: tabId, + view: this.createView(), + domReadyAt: null, + loadError: null, + }; + + this.wireEvents(tab); + this.tabs.set(tabId, tab); + this.tabOrder.push(tabId); + this.activeTabId = tabId; + this.invalidateSnapshot(tabId); + this.syncAttachedView(); + this.emitState(); + + const targetUrl = + initialUrl === 'about:blank' + ? HOME_URL + : normalizeNavigationTarget(initialUrl); + void tab.view.webContents.loadURL(targetUrl).catch((error) => { + tab.loadError = error instanceof Error + ? error.message + : `Failed to load ${targetUrl}.`; + this.emitState(); + }); + + return tab; + } + + private ensureInitialTab(): BrowserTab { + const activeTab = this.getActiveTab(); + if (activeTab) return activeTab; + return this.createTab(HOME_URL); + } + + private destroyTab(tab: BrowserTab): void { + this.invalidateSnapshot(tab.id); + tab.view.webContents.removeAllListeners(); + if (!tab.view.webContents.isDestroyed()) { + tab.view.webContents.close(); + } + } + + private async waitForWebContentsSettle( + tab: BrowserTab, + signal?: AbortSignal, + idleMs = POST_ACTION_IDLE_MS, + timeoutMs = NAVIGATION_TIMEOUT_MS, + ): Promise { + const wc = tab.view.webContents; + const startedAt = Date.now(); + let sawLoading = wc.isLoading(); + + while (Date.now() - startedAt < timeoutMs) { + abortIfNeeded(signal); + if (wc.isDestroyed()) return; + if (tab.loadError) { + throw new Error(tab.loadError); + } + + if (tab.domReadyAt != null) { + const domReadyForMs = Date.now() - tab.domReadyAt; + const requiredIdleMs = sawLoading ? idleMs : Math.min(idleMs, 200); + if (domReadyForMs >= requiredIdleMs) return; + await sleep(Math.min(100, requiredIdleMs - domReadyForMs), signal); + continue; + } + + if (wc.isLoading()) { + sawLoading = true; + await sleep(100, signal); + continue; + } + + await sleep(sawLoading ? idleMs : Math.min(idleMs, 200), signal); + if (tab.loadError) { + throw new Error(tab.loadError); + } + if (!wc.isLoading() || tab.domReadyAt != null) return; + sawLoading = true; + } + } + + private async executeOnActiveTab( + script: string, + signal?: AbortSignal, + options?: { waitForReady?: boolean }, + ): Promise { + abortIfNeeded(signal); + const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); + if (options?.waitForReady !== false) { + await this.waitForWebContentsSettle(activeTab, signal); + } + abortIfNeeded(signal); + return activeTab.view.webContents.executeJavaScript(script, true) as Promise; + } + + private cacheSnapshot(tabId: string, rawSnapshot: RawBrowserPageSnapshot, loading: boolean): BrowserPageSnapshot { + const snapshotId = randomUUID(); + const elements: BrowserPageElement[] = rawSnapshot.elements.map((element, index) => { + const { selector, ...rest } = element; + void selector; + return { + ...rest, + index: index + 1, + }; + }); + + this.snapshotCache.set(tabId, { + snapshotId, + elements: rawSnapshot.elements.map((element, index) => ({ + index: index + 1, + selector: element.selector, + })), + }); + + return { + snapshotId, + url: rawSnapshot.url, + title: rawSnapshot.title, + loading, + text: rawSnapshot.text, + elements, + }; + } + + private resolveElementSelector(tabId: string, target: ElementTarget): { ok: true; selector: string } | { ok: false; error: string } { + if (target.selector?.trim()) { + return { ok: true, selector: target.selector.trim() }; + } + + if (target.index == null) { + return { ok: false, error: 'Provide an element index or selector.' }; + } + + const cachedSnapshot = this.snapshotCache.get(tabId); + if (!cachedSnapshot) { + return { ok: false, error: 'No page snapshot is available yet. Call read-page first.' }; + } + + if (target.snapshotId && cachedSnapshot.snapshotId !== target.snapshotId) { + return { ok: false, error: 'The page changed since the last read-page call. Call read-page again.' }; + } + + const entry = cachedSnapshot.elements.find((element) => element.index === target.index); + if (!entry) { + return { ok: false, error: `No element found for index ${target.index}.` }; + } + + return { ok: true, selector: entry.selector }; + } + + setVisible(visible: boolean): void { + this.visible = visible; + if (visible) { + this.ensureInitialTab(); + } + this.syncAttachedView(); + } + + setBounds(bounds: BrowserBounds): void { + this.bounds = bounds; + const activeTab = this.getActiveTab(); + if (activeTab && this.attachedTabId === activeTab.id && this.visible) { + activeTab.view.setBounds(bounds); + } + } + + async ensureActiveTabReady(signal?: AbortSignal): Promise { + const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); + await this.waitForWebContentsSettle(activeTab, signal); + } + + async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> { + try { + const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL); + return { ok: true, tabId: tab.id }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + switchTab(tabId: string): { ok: boolean } { + if (!this.tabs.has(tabId)) return { ok: false }; + if (this.activeTabId === tabId) return { ok: true }; + this.activeTabId = tabId; + this.syncAttachedView(); + this.emitState(); + return { ok: true }; + } + + closeTab(tabId: string): { ok: boolean } { + const tab = this.tabs.get(tabId); + if (!tab) return { ok: false }; + if (this.tabOrder.length <= 1) return { ok: false }; + + const closingIndex = this.tabOrder.indexOf(tabId); + const nextActiveTabId = + this.activeTabId === tabId + ? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null + : this.activeTabId; + + if (this.attachedTabId === tabId && this.window) { + this.window.contentView.removeChildView(tab.view); + this.attachedTabId = null; + } + + this.tabs.delete(tabId); + this.tabOrder = this.tabOrder.filter((id) => id !== tabId); + this.activeTabId = nextActiveTabId; + this.destroyTab(tab); + this.syncAttachedView(); + this.emitState(); + + return { ok: true }; + } + + async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> { + try { + const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); + this.invalidateSnapshot(activeTab.id); + await activeTab.view.webContents.loadURL(normalizeNavigationTarget(rawUrl)); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + back(): { ok: boolean } { + const activeTab = this.getActiveTab(); + if (!activeTab) return { ok: false }; + const history = activeTab.view.webContents.navigationHistory; + if (!history.canGoBack()) return { ok: false }; + this.invalidateSnapshot(activeTab.id); + history.goBack(); + return { ok: true }; + } + + forward(): { ok: boolean } { + const activeTab = this.getActiveTab(); + if (!activeTab) return { ok: false }; + const history = activeTab.view.webContents.navigationHistory; + if (!history.canGoForward()) return { ok: false }; + this.invalidateSnapshot(activeTab.id); + history.goForward(); + return { ok: true }; + } + + reload(): void { + const activeTab = this.getActiveTab(); + if (!activeTab) return; + this.invalidateSnapshot(activeTab.id); + activeTab.view.webContents.reload(); + } + + async readPage( + options?: { maxElements?: number; maxTextLength?: number; waitForReady?: boolean }, + signal?: AbortSignal, + ): Promise<{ ok: boolean; page?: BrowserPageSnapshot; error?: string }> { + try { + const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); + const rawSnapshot = await this.executeOnActiveTab( + buildReadPageScript( + options?.maxElements ?? DEFAULT_READ_MAX_ELEMENTS, + options?.maxTextLength ?? DEFAULT_READ_MAX_TEXT_LENGTH, + ), + signal, + { waitForReady: options?.waitForReady }, + ); + return { + ok: true, + page: this.cacheSnapshot(activeTab.id, rawSnapshot, activeTab.view.webContents.isLoading()), + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to read the current page.', + }; + } + } + + async readPageSummary( + signal?: AbortSignal, + options?: { waitForReady?: boolean }, + ): Promise { + const result = await this.readPage( + { + maxElements: POST_ACTION_MAX_ELEMENTS, + maxTextLength: POST_ACTION_MAX_TEXT_LENGTH, + waitForReady: options?.waitForReady, + }, + signal, + ); + return result.ok ? result.page ?? null : null; + } + + async click(target: ElementTarget, signal?: AbortSignal): Promise<{ ok: boolean; error?: string; description?: string }> { + const activeTab = this.getActiveTab(); + if (!activeTab) { + return { ok: false, error: 'No active browser tab is open.' }; + } + + const resolved = this.resolveElementSelector(activeTab.id, target); + if (!resolved.ok) return resolved; + + try { + const result = await this.executeOnActiveTab<{ + ok: boolean; + error?: string; + description?: string; + clickPoint?: { + x: number; + y: number; + }; + verification?: { + before: unknown; + targetSelector: string | null; + }; + }>( + buildClickScript(resolved.selector), + signal, + ); + if (!result.ok) return result; + if (!result.clickPoint) { + return { + ok: false, + error: 'Could not determine where to click on the page.', + }; + } + + this.window?.focus(); + activeTab.view.webContents.focus(); + activeTab.view.webContents.sendInputEvent({ + type: 'mouseMove', + x: result.clickPoint.x, + y: result.clickPoint.y, + movementX: 0, + movementY: 0, + }); + activeTab.view.webContents.sendInputEvent({ + type: 'mouseDown', + x: result.clickPoint.x, + y: result.clickPoint.y, + button: 'left', + clickCount: 1, + }); + activeTab.view.webContents.sendInputEvent({ + type: 'mouseUp', + x: result.clickPoint.x, + y: result.clickPoint.y, + button: 'left', + clickCount: 1, + }); + + this.invalidateSnapshot(activeTab.id); + await this.waitForWebContentsSettle(activeTab, signal); + + if (result.verification) { + const verification = await this.executeOnActiveTab<{ changed: boolean; reasons: string[] }>( + buildVerifyClickScript(result.verification.targetSelector, result.verification.before), + signal, + { waitForReady: false }, + ); + + if (!verification.changed) { + return { + ok: false, + error: 'Click did not change the page state. Target may not be the correct control.', + description: result.description, + }; + } + } + + return result; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to click the element.', + }; + } + } + + async type(target: ElementTarget, text: string, signal?: AbortSignal): Promise<{ ok: boolean; error?: string; description?: string }> { + const activeTab = this.getActiveTab(); + if (!activeTab) { + return { ok: false, error: 'No active browser tab is open.' }; + } + + const resolved = this.resolveElementSelector(activeTab.id, target); + if (!resolved.ok) return resolved; + + try { + const result = await this.executeOnActiveTab<{ ok: boolean; error?: string; description?: string }>( + buildTypeScript(resolved.selector, text), + signal, + ); + if (!result.ok) return result; + this.invalidateSnapshot(activeTab.id); + await this.waitForWebContentsSettle(activeTab, signal); + return result; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to type into the element.', + }; + } + } + + async press( + key: string, + target?: ElementTarget, + signal?: AbortSignal, + ): Promise<{ ok: boolean; error?: string; description?: string }> { + const activeTab = this.getActiveTab(); + if (!activeTab) { + return { ok: false, error: 'No active browser tab is open.' }; + } + + let description = 'active element'; + + if (target?.index != null || target?.selector?.trim()) { + const resolved = this.resolveElementSelector(activeTab.id, target); + if (!resolved.ok) return resolved; + + try { + const focusResult = await this.executeOnActiveTab<{ ok: boolean; error?: string; description?: string }>( + buildFocusScript(resolved.selector), + signal, + ); + if (!focusResult.ok) return focusResult; + description = focusResult.description ?? description; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to focus the element before pressing a key.', + }; + } + } + + try { + const wc = activeTab.view.webContents; + const keyCode = normalizeKeyCode(key); + wc.sendInputEvent({ type: 'keyDown', keyCode }); + if (keyCode.length === 1) { + wc.sendInputEvent({ type: 'char', keyCode }); + } + wc.sendInputEvent({ type: 'keyUp', keyCode }); + + this.invalidateSnapshot(activeTab.id); + await this.waitForWebContentsSettle(activeTab, signal); + + return { + ok: true, + description: `${keyCode} on ${description}`, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to press the requested key.', + }; + } + } + + async scroll(direction: 'up' | 'down' = 'down', amount = 700, signal?: AbortSignal): Promise<{ ok: boolean; error?: string }> { + const activeTab = this.getActiveTab(); + if (!activeTab) { + return { ok: false, error: 'No active browser tab is open.' }; + } + + try { + const offset = Math.max(1, amount) * (direction === 'up' ? -1 : 1); + const result = await this.executeOnActiveTab<{ ok: boolean; error?: string }>( + buildScrollScript(offset), + signal, + ); + if (!result.ok) return result; + this.invalidateSnapshot(activeTab.id); + await sleep(250, signal); + return result; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to scroll the page.', + }; + } + } + + async wait(ms = 1000, signal?: AbortSignal): Promise { + await sleep(ms, signal); + const activeTab = this.getActiveTab(); + if (!activeTab) return; + await this.waitForWebContentsSettle(activeTab, signal); + } + + getState(): BrowserState { + return this.snapshotState(); + } + + private snapshotState(): BrowserState { + if (this.tabOrder.length === 0) return { ...EMPTY_STATE }; + return { + activeTabId: this.activeTabId, + tabs: this.tabOrder + .map((tabId) => this.tabs.get(tabId)) + .filter((tab): tab is BrowserTab => tab != null) + .map((tab) => this.snapshotTabState(tab)), + }; + } +} + +export const browserViewManager = new BrowserViewManager(); diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 36470d3e..274cfb2a 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -2,18 +2,21 @@ 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 type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; -import { z } from 'zod'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/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'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; -// Store active OAuth flows +// Store active OAuth flows (keyed by toolkitSlug to prevent concurrent flows for the same toolkit) const activeFlows = new Map(); /** @@ -41,6 +44,7 @@ export async function isConfigured(): Promise<{ configured: boolean }> { export function setApiKey(apiKey: string): { success: boolean; error?: string } { try { composioClient.setApiKey(apiKey); + invalidateCopilotInstructionsCache(); return { success: true }; } catch (error) { return { @@ -125,13 +129,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ }; } - // Store flow state - const flowKey = `${toolkitSlug}-${Date.now()}`; - activeFlows.set(flowKey, { - toolkitSlug, - connectedAccountId, - authConfigId, - }); + // Abort any existing flow for this toolkit before starting a new one + const existingFlow = activeFlows.get(toolkitSlug); + if (existingFlow) { + console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`); + clearTimeout(existingFlow.timeout); + existingFlow.server.close(); + activeFlows.delete(toolkitSlug); + } // Save initial account state const account: LocalConnectedAccount = { @@ -145,9 +150,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ composioAccountsRepo.saveAccount(account); // Set up callback server - let cleanupTimeout: NodeJS.Timeout; + const timeoutRef: { current: NodeJS.Timeout | null } = { current: null }; let callbackHandled = false; - const { server } = await createAuthServer(8081, async (_code, _state) => { + const { server } = await createAuthServer(8081, async (_callbackUrl) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; @@ -157,6 +162,8 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); if (accountStatus.status === 'ACTIVE') { + // Invalidate instructions cache so the copilot knows about the new connection + invalidateCopilotInstructionsCache(); emitComposioEvent({ toolkitSlug, success: true }); if (toolkitSlug === 'gmail') { triggerGmailSync(); @@ -179,17 +186,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ error: error instanceof Error ? error.message : 'Unknown error', }); } finally { - activeFlows.delete(flowKey); + activeFlows.delete(toolkitSlug); server.close(); - clearTimeout(cleanupTimeout); + if (timeoutRef.current) clearTimeout(timeoutRef.current); } }); // Timeout for abandoned flows (5 minutes) - cleanupTimeout = setTimeout(() => { - if (activeFlows.has(flowKey)) { + const cleanupTimeout = setTimeout(() => { + if (activeFlows.has(toolkitSlug)) { console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`); - activeFlows.delete(flowKey); + activeFlows.delete(toolkitSlug); server.close(); emitComposioEvent({ toolkitSlug, @@ -198,6 +205,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ }); } }, 5 * 60 * 1000); + timeoutRef.current = cleanupTimeout; + + // Store flow state (keyed by toolkit to prevent concurrent flows) + activeFlows.set(toolkitSlug, { + toolkitSlug, + connectedAccountId, + authConfigId, + server, + timeout: cleanupTimeout, + }); // Open browser for OAuth shell.openExternal(redirectUrl); @@ -257,18 +274,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea try { const account = composioAccountsRepo.getAccount(toolkitSlug); if (account) { - // Delete from Composio await composioClient.deleteConnectedAccount(account.id); - // Delete local record - composioAccountsRepo.deleteAccount(toolkitSlug); } - return { success: true }; } catch (error) { console.error('[Composio] Disconnect failed:', error); - // Still delete local record even if API call fails + } finally { + // Always clean up local state, even if the API call fails composioAccountsRepo.deleteAccount(toolkitSlug); - return { success: true }; + invalidateCopilotInstructionsCache(); } + return { success: true }; } /** @@ -293,36 +308,24 @@ export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean } /** - * Execute a Composio action + * List available Composio toolkits — filtered to curated list only. + * Return type matches the ZToolkit schema from core/composio/types.ts. */ -export async function executeAction( - actionSlug: string, - toolkitSlug: string, - input: Record -): Promise> { - try { - const account = composioAccountsRepo.getAccount(toolkitSlug); - if (!account || account.status !== 'ACTIVE') { - return { - data: null, - successful: false, - error: `Toolkit ${toolkitSlug} is not connected`, - }; - } - - const result = await composioClient.executeAction(actionSlug, { - connected_account_id: account.id, - user_id: 'rowboat-user', - version: 'latest', - arguments: input, - }); - return result; - } catch (error) { - console.error('[Composio] Action execution failed:', error); - return { - successful: false, - data: null, - error: error instanceof Error ? error.message : 'Unknown error', - }; +export async function listToolkits() { + // Paginate through all API pages to collect every curated toolkit + const allItems: Toolkit[] = []; + let cursor: string | null = null; + const maxPages = 10; // safety limit + for (let page = 0; page < maxPages; page++) { + const result = await composioClient.listToolkits(cursor); + allItems.push(...result.items); + cursor = result.next_cursor; + if (!cursor) break; } + const filtered = allItems.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug)); + return { + items: filtered, + nextCursor: null as string | null, + totalItems: filtered.length, + }; } diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 64bd34a7..61c130df 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -44,6 +44,17 @@ import { versionHistory, voice } from '@x/core'; import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; 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 { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js'; +import { trackBus } from '@x/core/dist/knowledge/track/bus.js'; +import { + fetchYaml, + updateTrackBlock, + replaceTrackBlockYaml, + deleteTrackBlock, +} from '@x/core/dist/knowledge/track/fileops.js'; +import { browserIpcHandlers } from './browser/ipc.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -110,6 +121,18 @@ function markdownToHtml(markdown: string, title: string): string { ${html}` } +function resolveShellPath(filePath: string): string { + if (filePath.startsWith('~')) { + return path.join(os.homedir(), filePath.slice(1)); + } + + if (path.isAbsolute(filePath)) { + return filePath; + } + + return workspace.resolveWorkspacePath(filePath); +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -146,10 +169,10 @@ export function registerIpcHandlers(handlers: InvokeHandlers) { ipcMain.handle(channel, async (event, rawArgs) => { // Validate request payload const args = ipc.validateRequest(channel, rawArgs); - + // Call handler const result = await handler(event, args); - + // Validate response payload return ipc.validateResponse(channel, result); }); @@ -271,7 +294,7 @@ function handleWorkspaceChange(event: z.infer { }); } +let tracksWatcher: (() => void) | null = null; +export function startTracksWatcher(): void { + if (tracksWatcher) return; + tracksWatcher = trackBus.subscribe((event) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('tracks:events', event); + } + } + }); +} + export function stopRunsWatcher(): void { if (runsWatcher) { runsWatcher(); @@ -421,7 +457,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -460,7 +496,10 @@ export function setupIpcHandlers() { return { success: true }; }, 'oauth:connect': async (_event, args) => { - return await connectProvider(args.provider, args.clientId?.trim()); + const credentials = args.clientId && args.clientSecret + ? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() } + : undefined; + return await connectProvider(args.provider, credentials); }, 'oauth:disconnect': async (_event, args) => { return await disconnectProvider(args.provider); @@ -473,6 +512,21 @@ export function setupIpcHandlers() { const config = await repo.getClientFacingConfig(); return { config }; }, + 'account:getRowboat': async () => { + const signedIn = await isSignedIn(); + if (!signedIn) { + return { signedIn: false, accessToken: null, config: null }; + } + + const config = await getRowboatConfig(); + + try { + const accessToken = await getAccessToken(); + return { signedIn: true, accessToken, config }; + } catch { + return { signedIn: true, accessToken: null, config }; + } + }, 'granola:getConfig': async () => { const repo = container.resolve('granolaConfigRepo'); const config = await repo.getConfig(); @@ -544,8 +598,9 @@ export function setupIpcHandlers() { 'composio:list-connected': async () => { return composioHandler.listConnected(); }, - 'composio:execute-action': async (_event, args) => { - return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); + // Composio Tools Library handlers + 'composio:list-toolkits': async () => { + return composioHandler.listToolkits(); }, 'composio:use-composio-for-google': async () => { return composioHandler.useComposioForGoogle(); @@ -588,24 +643,12 @@ export function setupIpcHandlers() { }, // Shell integration handlers 'shell:openPath': async (_event, args) => { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const error = await shell.openPath(filePath); return { error: error || undefined }; }, 'shell:readFileBase64': async (_event, args) => { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const stat = await fs.stat(filePath); if (stat.size > 10 * 1024 * 1024) { throw new Error('File too large (>10MB)'); @@ -704,6 +747,24 @@ export function setupIpcHandlers() { return { success: false, error: 'Unknown format' }; }, + 'meeting:checkScreenPermission': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('screen'); + console.log('[meeting] Screen recording permission status:', status); + if (status === 'granted') return { granted: true }; + // Not granted — call desktopCapturer.getSources() to register the app + // in the macOS Screen Recording list. On first call this shows the + // native permission prompt (signed apps are remembered across restarts). + try { await desktopCapturer.getSources({ types: ['screen'] }); } catch { /* ignore */ } + // Re-check after the native prompt was dismissed + const statusAfter = systemPreferences.getMediaAccessStatus('screen'); + console.log('[meeting] Screen recording permission status after prompt:', statusAfter); + return { granted: statusAfter === 'granted' }; + }, + 'meeting:openScreenRecordingSettings': async () => { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); + return { success: true }; + }, 'meeting:summarize': async (_event, args) => { const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson); return { notes }; @@ -721,8 +782,47 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, - 'voice:getDeepgramToken': async () => { - return voice.getDeepgramToken(); + // Track handlers + 'track:run': async (_event, args) => { + const result = await triggerTrackUpdate(args.trackId, args.filePath); + return { success: !result.error, summary: result.summary ?? undefined, error: result.error }; + }, + 'track:get': async (_event, args) => { + try { + const yaml = await fetchYaml(args.filePath, args.trackId); + if (yaml === null) return { success: false, error: 'Track not found' }; + return { success: true, yaml }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:update': async (_event, args) => { + try { + await updateTrackBlock(args.filePath, args.trackId, args.updates as Record); + const yaml = await fetchYaml(args.filePath, args.trackId); + if (yaml === null) return { success: false, error: 'Track vanished after update' }; + return { success: true, yaml }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:replaceYaml': async (_event, args) => { + try { + await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml); + const yaml = await fetchYaml(args.filePath, args.trackId); + if (yaml === null) return { success: false, error: 'Track vanished after replace' }; + return { success: true, yaml }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:delete': async (_event, args) => { + try { + await deleteTrackBlock(args.filePath, args.trackId); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } }, // Skills handlers 'skills:list': async () => { @@ -752,5 +852,7 @@ export function setupIpcHandlers() { 'billing:getInfo': async () => { return await getBillingInfo(); }, + // Embedded browser handlers (WebContentsView + navigation) + ...browserIpcHandlers, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 43f11da3..382f4ed7 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,9 +1,10 @@ -import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron"; +import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron"; import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, + startTracksWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -22,9 +23,22 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initSkillSync } from "@x/core/dist/skills/sync.js"; +import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; +import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; +import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; +import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; + import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; -import { execSync } from "node:child_process"; +import { execSync, exec, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; +import { registerBrowserControlService } from "@x/core/dist/di/container.js"; +import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; +import { setupBrowserEventForwarding } from "./browser/ipc.js"; +import { ElectronBrowserControlService } from "./browser/control-service.js"; + +const execAsync = promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -34,25 +48,30 @@ if (started) app.quit(); // Fix PATH for packaged Electron apps on macOS/Linux. // Packaged apps inherit a minimal environment that doesn't include paths from -// the user's shell profile (nvm, Homebrew, etc.). Spawn the user's login shell -// to resolve the full PATH, using delimiters to safely extract it from any -// surrounding shell output (motd, greeting messages, etc.). -if (process.platform !== 'win32') { +// the user's shell profile (such as those provided by nvm, Homebrew, etc.). +// The function below spawns the user's login shell and runs a Node.js one-liner +// to print the full environment as JSON, then merges it into process.env. +// This ensures the Electron app has the same PATH and environment as user shell +// (helping find tools installed via Homebrew/nvm/npm, etc.) +function initializeExecutionEnvironment(): void { + if (process.platform === 'win32') return; + + const shell = process.env.SHELL || '/bin/zsh'; + try { - const userShell = process.env.SHELL || '/bin/zsh'; - const delimiter = '__ROWBOAT_PATH__'; - const output = execSync( - `${userShell} -lc 'echo -n "${delimiter}$PATH${delimiter}"'`, - { encoding: 'utf-8', timeout: 5000 }, - ); - const match = output.match(new RegExp(`${delimiter}(.+?)${delimiter}`)); - if (match?.[1]) { - process.env.PATH = match[1]; - } - } catch { - // Silently fall back to the existing PATH if shell resolution fails + const stdout = execFileSync( + shell, + ['-l', '-c', `node -p "JSON.stringify(process.env)"`], + { encoding: 'utf8' } + ).trim(); + + const env = JSON.parse(stdout) as Record; + process.env = { ...env, ...process.env }; + } catch (error) { + console.error('Failed to load shell environment', error); } } +initializeExecutionEnvironment(); // Path resolution differs between development and production: const preloadPath = app.isPackaged @@ -99,10 +118,36 @@ protocol.registerSchemesAsPrivileged([ }, ]); +const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]); + +function configureSessionPermissions(targetSession: Session): void { + targetSession.setPermissionCheckHandler((_webContents, permission) => { + return ALLOWED_SESSION_PERMISSIONS.has(permission); + }); + + targetSession.setPermissionRequestHandler((_webContents, permission, callback) => { + callback(ALLOWED_SESSION_PERMISSIONS.has(permission)); + }); + + // Auto-approve display media requests and route system audio as loopback. + // Electron requires a video source in the callback even if we only want audio. + // We pass the first available screen source; the renderer discards the video track. + targetSession.setDisplayMediaRequestHandler(async (_request, callback) => { + const sources = await desktopCapturer.getSources({ types: ['screen'] }); + if (sources.length === 0) { + callback({}); + return; + } + callback({ video: sources[0], audio: 'loopback' }); + }); +} + function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, + minWidth: 600, + minHeight: 480, show: false, // Don't show until ready backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", @@ -116,26 +161,8 @@ function createWindow() { }, }); - // Grant microphone and display-capture permissions - session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - if (permission === 'media' || permission === 'display-capture') { - callback(true); - } else { - callback(false); - } - }); - - // Auto-approve display media requests and route system audio as loopback. - // Electron requires a video source in the callback even if we only want audio. - // We pass the first available screen source; the renderer discards the video track. - session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { - const sources = await desktopCapturer.getSources({ types: ['screen'] }); - if (sources.length === 0) { - callback({}); - return; - } - callback({ video: sources[0], audio: 'loopback' }); - }); + configureSessionPermissions(session.defaultSession); + configureSessionPermissions(session.fromPartition(BROWSER_PARTITION)); // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { @@ -160,6 +187,10 @@ function createWindow() { } }); + // Attach the embedded browser pane manager to this window. + // The WebContentsView is created lazily on first `browser:setVisible`. + browserViewManager.attach(win); + if (app.isPackaged) { win.loadURL("app://-/index.html"); } else { @@ -184,10 +215,26 @@ app.whenReady().then(async () => { }); } + // Ensure agent-slack CLI is available + try { + execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 }); + } catch { + try { + console.log('agent-slack not found, installing...'); + await execAsync('npm install -g agent-slack', { timeout: 60000 }); + console.log('agent-slack installed successfully'); + } catch (e) { + console.error('Failed to install agent-slack:', e); + } + } + // Initialize all config files before UI can access them await initConfigs(); + registerBrowserControlService(new ElectronBrowserControlService()); + setupIpcHandlers(); + setupBrowserEventForwarding(); createWindow(); @@ -204,6 +251,15 @@ app.whenReady().then(async () => { // start services watcher startServicesWatcher(); + // start tracks watcher + startTracksWatcher(); + + // start track scheduler (cron/window/once) + initTrackScheduler(); + + // start track event processor (consumes events/pending/, triggers matching tracks) + initTrackEventProcessor(); + // start gmail sync initGmailSync(); @@ -231,9 +287,20 @@ app.whenReady().then(async () => { // start background agent runner (scheduled agents) initAgentRunner(); - // start skill sync service (pulls from GitHub repo hourly) + // start skill sync service initSkillSync(); + // start agent notes learning service + initAgentNotes(); + + // start chrome extension sync server + initChromeSync(); + + // start local sites server for iframe dashboards and other mini apps + initLocalSites().catch((error) => { + console.error('[LocalSites] Failed to start:', error); + }); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -252,4 +319,7 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + shutdownLocalSites().catch((error) => { + console.error('[LocalSites] Failed to shut down cleanly:', error); + }); }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index b38a42d2..3bb9063b 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -11,9 +11,47 @@ import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gma import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent } from './ipc.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +/** Top-level openid-client messages that often wrap a more specific cause. */ +const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); + +function firstCauseMessage(error: unknown): string | undefined { + if (error == null || typeof error !== 'object' || !('cause' in error)) { + return undefined; + } + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error && cause.message.trim()) { + return cause.message; + } + if (typeof cause === 'string' && cause.trim()) { + return cause; + } + return undefined; +} + +/** + * User-facing message for token-exchange failures. Prefer the first cause message when + * the top-level message is opaque (common for openid-client) or when code is OAUTH_INVALID_RESPONSE. + * The catch block below still logs the full cause chain for any error; this helper stays conservative. + */ +function getOAuthErrorMessage(error: unknown): string { + const msg = error instanceof Error ? error.message : 'Unknown error'; + const code = error != null && typeof error === 'object' && 'code' in error + ? (error as { code?: string }).code + : undefined; + const causeMsg = firstCauseMessage(error); + if (code === 'OAUTH_INVALID_RESPONSE' && causeMsg) { + return causeMsg; + } + if (causeMsg && OPAQUE_OAUTH_TOP_MESSAGES.has(msg.trim().toLowerCase())) { + return causeMsg; + } + return msg; +} + // Store active OAuth flows (state -> { codeVerifier, provider, config }) const activeFlows = new Map { - const config = getProviderConfig(provider); - const resolveClientId = async (): Promise => { +async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise { + const config = await getProviderConfig(provider); + const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => { if (config.client.mode === 'static' && config.client.clientId) { - return config.client.clientId; + return { clientId: config.client.clientId, clientSecret: credentialsOverride?.clientSecret }; } - if (clientIdOverride) { - return clientIdOverride; + if (credentialsOverride) { + return { clientId: credentialsOverride.clientId, clientSecret: credentialsOverride.clientSecret }; } const oauthRepo = getOAuthRepo(); - const { clientId } = await oauthRepo.read(provider); - if (clientId) { - return clientId; + const connection = await oauthRepo.read(provider); + if (connection.clientId) { + return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined }; } throw new Error(`${provider} client ID not configured. Please provide a client ID.`); }; @@ -95,10 +133,11 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str if (config.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`); - const clientId = await resolveClientId(); + const { clientId, clientSecret } = await resolveClientCredentials(); return await oauthClient.discoverConfiguration( config.discovery.issuer, - clientId + clientId, + clientSecret ); } else { // DCR mode - check for existing registration or register new @@ -135,12 +174,13 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str } console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); - const clientId = await resolveClientId(); + const { clientId, clientSecret } = await resolveClientCredentials(); return oauthClient.createStaticConfiguration( config.discovery.authorizationEndpoint, config.discovery.tokenEndpoint, clientId, - config.discovery.revocationEndpoint + config.discovery.revocationEndpoint, + clientSecret ); } } @@ -148,7 +188,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str /** * Initiate OAuth flow for a provider */ -export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> { +export async function connectProvider(provider: string, credentials?: { clientId: string; clientSecret: string }): Promise<{ success: boolean; error?: string }> { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); @@ -156,16 +196,16 @@ export async function connectProvider(provider: string, clientId?: string): Prom cancelActiveFlow('new_flow_started'); const oauthRepo = getOAuthRepo(); - const providerConfig = getProviderConfig(provider); + const providerConfig = await getProviderConfig(provider); if (provider === 'google') { - if (!clientId) { - return { success: false, error: 'Google client ID is required to connect.' }; + if (!credentials?.clientId || !credentials?.clientSecret) { + return { success: false, error: 'Google client ID and client secret are required to connect.' }; } } // Get or create OAuth configuration - const config = await getProviderConfiguration(provider, clientId); + const config = await getProviderConfiguration(provider, credentials); // Generate PKCE codes const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); @@ -187,11 +227,16 @@ export async function connectProvider(provider: string, clientId?: string): Prom // Create callback server let callbackHandled = false; - const { server } = await createAuthServer(8080, async (code, receivedState) => { + const { server } = await createAuthServer(8080, async (callbackUrl) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; - // Validate state + const receivedState = callbackUrl.searchParams.get('state'); + if (receivedState == null || receivedState === '') { + throw new Error( + 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' + ); + } if (receivedState !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); } @@ -202,10 +247,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom } try { - // Build callback URL for token exchange - const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); - - // Exchange code for tokens + // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); const tokens = await oauthClient.exchangeCodeForTokens( flow.config, @@ -214,13 +256,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom state ); - // Save tokens + // Save tokens and credentials console.log(`[OAuth] Token exchange successful for ${provider}`); - await oauthRepo.upsert(provider, { tokens }); - if (provider === 'google' && clientId) { - await oauthRepo.upsert(provider, { clientId }); - } - await oauthRepo.upsert(provider, { error: null }); + await oauthRepo.upsert(provider, { + tokens, + ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + error: null, + }); // Trigger immediate sync for relevant providers if (provider === 'google') { @@ -230,11 +272,30 @@ export async function connectProvider(provider: string, clientId?: string): Prom triggerFirefliesSync(); } + // For Rowboat sign-in, ensure user + Stripe customer exist before + // notifying the renderer. Without this, parallel API calls from + // multiple renderer hooks race to create the user, causing duplicates. + if (provider === 'rowboat') { + try { + await getBillingInfo(); + } catch (meError) { + console.error('[OAuth] Failed to initialize user via /v1/me:', meError); + } + } + // Emit success event to renderer emitOAuthEvent({ provider, success: true }); } catch (error) { console.error('OAuth token exchange failed:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) + let cause: unknown = error; + while (cause != null && typeof cause === 'object' && 'cause' in cause) { + cause = (cause as { cause?: unknown }).cause; + if (cause != null) { + console.error('[OAuth] Caused by:', cause); + } + } + const errorMessage = getOAuthErrorMessage(error); emitOAuthEvent({ provider, success: false, error: errorMessage }); throw error; } finally { @@ -302,8 +363,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b export async function getAccessToken(provider: string): Promise { try { const oauthRepo = getOAuthRepo(); - - const { tokens } = await oauthRepo.read(provider); + + let { tokens } = await oauthRepo.read(provider); if (!tokens) { return null; } @@ -319,11 +380,12 @@ export async function getAccessToken(provider: string): Promise { try { // Get configuration for refresh const config = await getProviderConfiguration(provider); - + // Refresh token, preserving existing scopes const existingScopes = tokens.scopes; const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes); - await oauthRepo.upsert(provider, { tokens }); + await oauthRepo.upsert(provider, { tokens: refreshedTokens }); + tokens = refreshedTokens; } catch (error) { const message = error instanceof Error ? error.message : 'Token refresh failed'; await oauthRepo.upsert(provider, { error: message }); diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts index 836deea7..738d861a 100644 --- a/apps/x/apps/main/src/test-agent.ts +++ b/apps/x/apps/main/src/test-agent.ts @@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js'; async function main() { const { id } = await runsCore.createRun({ - // this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md + // this expects an agent file to exist at WorkDir/agents/test-agent.md agentId: 'test-agent', }); console.log(`created run: ${id}`); @@ -16,4 +16,4 @@ async function main() { console.log(`created message: ${msgId}`); } -main(); \ No newline at end of file +main(); diff --git a/apps/x/apps/preload/src/preload.ts b/apps/x/apps/preload/src/preload.ts index 7d7d53e4..bc69d4bb 100644 --- a/apps/x/apps/preload/src/preload.ts +++ b/apps/x/apps/preload/src/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, webUtils } from 'electron'; +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'; import { ipc as ipcShared } from '@x/shared'; type InvokeChannels = ipcShared.InvokeChannels; @@ -55,4 +55,5 @@ contextBridge.exposeInMainWorld('ipc', ipc); contextBridge.exposeInMainWorld('electronUtils', { getPathForFile: (file: File) => webUtils.getPathForFile(file), -}); \ No newline at end of file + getZoomFactor: () => webFrame.getZoomFactor(), +}); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index ebf8a650..a8c67a43 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -28,6 +28,7 @@ "@tiptap/extension-image": "^3.16.0", "@tiptap/extension-link": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", + "@tiptap/extension-table": "^3.22.4", "@tiptap/extension-task-item": "^3.15.3", "@tiptap/extension-task-list": "^3.15.3", "@tiptap/pm": "^3.15.3", @@ -40,6 +41,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.562.0", + "mermaid": "^11.14.0", "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", @@ -54,6 +56,7 @@ "tiptap-markdown": "^0.9.0", "tokenlens": "^1.3.1", "use-stick-to-bottom": "^1.1.1", + "yaml": "^2.8.2", "zod": "^4.2.1" }, "devDependencies": { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 20fa5435..602c0956 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,9 +5,9 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { MarkdownEditor } from './components/markdown-editor'; +import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' @@ -15,12 +15,13 @@ import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-vi import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; +import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { Message, @@ -33,9 +34,11 @@ import { } from '@/components/ai-elements/prompt-input'; import { Shimmer } from '@/components/ai-elements/shimmer'; -import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; +import { useSmoothedText } from './hooks/useSmoothedText'; +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; +import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; @@ -52,20 +55,25 @@ import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding' -import { SearchDialog } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' +import { TrackModal } from '@/components/track-modal' import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { type ChatMessage, + type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, type ToolCall, createEmptyChatTabViewState, getWebSearchCardData, getAppActionCardData, + getComposioConnectCardData, + getToolDisplayName, inferRunTitleFromMessage, isChatMessage, isErrorMessage, @@ -75,12 +83,15 @@ import { parseAttachedFiles, toToolState, } from '@/lib/chat-conversation' +import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' -import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' +import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' +import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' +import * as analytics from '@/lib/analytics' type DirEntry = z.infer type RunEventType = z.infer @@ -93,6 +104,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { + const smoothText = useSmoothedText(text) + return {smoothText} +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -111,9 +127,10 @@ const TITLEBAR_BUTTON_PX = 32 const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_HEADER_GAP_PX = 8 const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 -const TITLEBAR_BUTTONS_COLLAPSED = 5 -const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4 +const TITLEBAR_BUTTONS_COLLAPSED = 1 +const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' +const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -242,8 +259,63 @@ const getAncestorDirectoryPaths = (path: string): string[] => { } const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH +const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH +const getSuggestedTopicTargetFolder = (category?: string) => { + const normalized = category?.trim().toLowerCase() + switch (normalized) { + case 'people': + case 'person': + return 'People' + case 'organizations': + case 'organization': + return 'Organizations' + case 'projects': + case 'project': + return 'Projects' + case 'meetings': + case 'meeting': + return 'Meetings' + case 'topics': + case 'topic': + default: + return 'Topics' + } +} + +const buildSuggestedTopicExplorePrompt = ({ + title, + description, + category, +}: { + title: string + description: string + category?: string +}) => { + const folder = getSuggestedTopicTargetFolder(category) + const categoryLabel = category?.trim() || 'Topics' + return [ + 'I am exploring a suggested topic card from the Suggested Topics panel.', + 'This card may represent a person, organization, topic, or project.', + '', + 'Card context:', + `- Title: ${title}`, + `- Category: ${categoryLabel}`, + `- Description: ${description}`, + `- Target folder if we set this up: knowledge/${folder}/`, + '', + `Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`, + 'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.', + 'Do not create or modify anything yet.', + 'Treat a clear confirmation from me as explicit approval to proceed.', + `If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`, + `If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`, + 'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.', + 'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.', + ].join('\n') +} + const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -261,18 +333,49 @@ const normalizeUsage = (usage?: Partial | null): LanguageMod } } -// Pinned folders appear first in the sidebar (in this order) -const PINNED_FOLDERS = ['Notes'] +// Sidebar folder ordering — listed folders appear in this order, unlisted ones follow alphabetically +const FOLDER_ORDER = ['People', 'Organizations', 'Projects', 'Topics', 'Meetings', 'Agent Notes', 'Notes'] -// Sort nodes (dirs first, pinned folders at top, then alphabetically) +/** + * Per-folder base view config: which columns to show and default sort. + * Folders not listed here fall back to DEFAULT_BASE_CONFIG. + */ +const FOLDER_BASE_CONFIGS: Record = { + 'Agent Notes': { + visibleColumns: ['name', 'folder', 'mtimeMs'], + sort: { field: 'mtimeMs', dir: 'desc' }, + }, + People: { + visibleColumns: ['name', 'relationship', 'organization', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Organizations: { + visibleColumns: ['name', 'relationship', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Projects: { + visibleColumns: ['name', 'status', 'topic', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Topics: { + visibleColumns: ['name', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Meetings: { + visibleColumns: ['name', 'topic', 'mtimeMs'], + sort: { field: 'mtimeMs', dir: 'desc' }, + }, +} + +// Sort nodes (dirs first, ordered folders by FOLDER_ORDER, then alphabetically) function sortNodes(nodes: TreeNode[]): TreeNode[] { return nodes.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 - const aPinned = PINNED_FOLDERS.indexOf(a.name) - const bPinned = PINNED_FOLDERS.indexOf(b.name) - if (aPinned !== -1 && bPinned !== -1) return aPinned - bPinned - if (aPinned !== -1) return -1 - if (bPinned !== -1) return 1 + const aOrder = FOLDER_ORDER.indexOf(a.name) + const bOrder = FOLDER_ORDER.indexOf(b.name) + if (aOrder !== -1 && bOrder !== -1) return aOrder - bOrder + if (aOrder !== -1) return -1 + if (bOrder !== -1) return 1 return a.name.localeCompare(b.name) }).map(node => { if (node.children) { @@ -393,6 +496,7 @@ type ViewState = | { type: 'file'; path: string } | { type: 'graph' } | { type: 'task'; name: string } + | { type: 'suggested-topics' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -402,34 +506,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } -/** Sidebar toggle + back/forward nav */ +/** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, - onNewChat, - onOpenSearch, - meetingState, - meetingSummarizing, - meetingAvailable, - onToggleMeeting, leftInsetPx, }: { - onNavigateBack: () => void - onNavigateForward: () => void - canNavigateBack: boolean - canNavigateForward: boolean - onNewChat: () => void - onOpenSearch: () => void - meetingState: MeetingTranscriptionState - meetingSummarizing: boolean - meetingAvailable: boolean - onToggleMeeting: () => void leftInsetPx: number }) { - const { toggleSidebar, state } = useSidebar() - const isCollapsed = state === "collapsed" + const { toggleSidebar } = useSidebar() return (
) } @@ -540,15 +550,14 @@ function ContentHeader({ const isCollapsed = state === "collapsed" return (
- {!isCollapsed && onNavigateBack && onNavigateForward ? ( + {onNavigateBack && onNavigateForward ? (
) @@ -4314,6 +4684,7 @@ function App() { conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} + viewportAnchors={chatViewportAnchorByTab} isProcessing={isProcessing} isStopping={isStopping} onStop={handleStop} @@ -4347,32 +4718,26 @@ function App() { ttsMode={ttsMode} onToggleTts={handleToggleTts} onTtsModeChange={handleTtsModeChange} + onComposioConnected={handleComposioConnected} /> )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} { void navigateBack() }} - onNavigateForward={() => { void navigateForward() }} - canNavigateBack={canNavigateBack} - canNavigateForward={canNavigateForward} - onNewChat={handleNewChatTab} - onOpenSearch={() => setIsSearchOpen(true)} - meetingState={meetingTranscription.state} - meetingSummarizing={meetingSummarizing} - meetingAvailable={voiceAvailable} - onToggleMeeting={() => { void handleToggleMeeting() }} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} />
- { void navigateToView({ type: 'chat', runId: id }) }} + initialContext={paletteContext} + onChatSubmit={submitFromPalette} /> + - Meeting transcription setup + Screen recording permission required - Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). + Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it.

To enable this:

    -
  1. Open System SettingsPrivacy & Security
  2. -
  3. Click Screen Recording
  4. +
  5. Open System SettingsPrivacy & SecurityScreen Recording
  6. Toggle on Rowboat
  7. You may need to restart the app after granting permission
- + +
diff --git a/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx new file mode 100644 index 00000000..731eeb09 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CheckCircleIcon, + Link2Icon, + LoaderIcon, + XCircleIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ComposioConnectCardProps { + toolkitSlug: string; + toolkitDisplayName: string; + status: "pending" | "running" | "completed" | "error"; + alreadyConnected?: boolean; + onConnected?: (toolkitSlug: string) => void; +} + +export function ComposioConnectCard({ + toolkitSlug, + toolkitDisplayName, + status, + alreadyConnected, + onConnected, +}: ComposioConnectCardProps) { + const [connectionState, setConnectionState] = useState< + "idle" | "connecting" | "connected" | "error" + >(alreadyConnected ? "connected" : "idle"); + const [errorMessage, setErrorMessage] = useState(null); + const didFireCallback = useRef(alreadyConnected ?? false); + + // Listen for composio:didConnect events + useEffect(() => { + const cleanup = window.ipc.on( + "composio:didConnect", + (event: { toolkitSlug: string; success: boolean; error?: string }) => { + if (event.toolkitSlug !== toolkitSlug) return; + if (event.success) { + setConnectionState("connected"); + setErrorMessage(null); + if (!didFireCallback.current) { + didFireCallback.current = true; + onConnected?.(toolkitSlug); + } + } else { + setConnectionState("error"); + setErrorMessage(event.error || "Connection failed"); + } + } + ); + return cleanup; + }, [toolkitSlug, onConnected]); + + const handleConnect = useCallback(async () => { + setConnectionState("connecting"); + setErrorMessage(null); + try { + const result = await window.ipc.invoke("composio:initiate-connection", { + toolkitSlug, + }); + if (!result.success) { + setConnectionState("error"); + setErrorMessage(result.error || "Failed to initiate connection"); + } + } catch { + setConnectionState("error"); + setErrorMessage("Failed to initiate connection"); + } + }, [toolkitSlug]); + + const isToolRunning = status === "pending" || status === "running"; + const displayName = toolkitDisplayName || toolkitSlug; + + return ( +
+ {/* Toolkit initial */} +
+ + {displayName.charAt(0).toUpperCase()} + +
+ + {/* Name & status */} +
+
+ {displayName} + {connectionState === "connected" && ( + + Connected + + )} +
+ {connectionState === "error" && errorMessage && ( +

{errorMessage}

+ )} + {connectionState === "idle" && isToolRunning && ( +

Waiting to connect...

+ )} +
+ + {/* Action area */} + {connectionState === "connected" ? ( + + ) : connectionState === "connecting" ? ( + + ) : connectionState === "error" ? ( +
+ + +
+ ) : isToolRunning ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index f1d514da..7a3f8836 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -3,163 +3,254 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; +import type { ComponentProps, ReactNode, RefObject } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; -// Context to share scroll preservation state -interface ScrollPreservationContextValue { - registerScrollContainer: (container: HTMLElement | null) => void; - markUserEngaged: () => void; - resetEngagement: () => void; +const BOTTOM_THRESHOLD_PX = 8; +const MAX_ANCHOR_RETRIES = 6; + +interface ConversationContextValue { + contentRef: RefObject; + isAtBottom: boolean; + scrollRef: RefObject; + scrollToBottom: () => void; } -const ScrollPreservationContext = createContext(null); +const ConversationContext = createContext(null); -export type ConversationProps = ComponentProps & { +export type ConversationProps = ComponentProps<"div"> & { + anchorMessageId?: string | null; + anchorRequestKey?: number; children?: ReactNode; }; -export const Conversation = ({ className, children, ...props }: ConversationProps) => { - const [scrollContainer, setScrollContainer] = useState(null); - const isUserEngagedRef = useRef(false); - const savedScrollTopRef = useRef(0); - const lastScrollHeightRef = useRef(0); +export const Conversation = ({ + anchorMessageId = null, + anchorRequestKey, + children, + className, + ...props +}: ConversationProps) => { + const contentRef = useRef(null); + const scrollRef = useRef(null); + const spacerRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); - const contextValue: ScrollPreservationContextValue = { - registerScrollContainer: (container) => { - setScrollContainer(container); - }, - markUserEngaged: () => { - // Only save position on first engagement, not on repeated calls - if (!isUserEngagedRef.current && scrollContainer) { - savedScrollTopRef.current = scrollContainer.scrollTop; - lastScrollHeightRef.current = scrollContainer.scrollHeight; + const updateBottomState = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX); + }, []); + + const applyAnchorLayout = useCallback( + (scrollToAnchor: boolean): boolean => { + const container = scrollRef.current; + const content = contentRef.current; + const spacer = spacerRef.current; + + if (!container || !content || !spacer) { + return false; } - isUserEngagedRef.current = true; - }, - resetEngagement: () => { - isUserEngagedRef.current = false; - }, - }; - // Watch for content changes and restore scroll position if user was engaged + if (!anchorMessageId) { + spacer.style.height = "0px"; + updateBottomState(); + return true; + } + + const anchor = content.querySelector( + `[data-message-id="${anchorMessageId}"]` + ); + + if (!anchor) { + spacer.style.height = "0px"; + updateBottomState(); + return false; + } + + spacer.style.height = "0px"; + + const contentPaddingTop = Number.parseFloat( + window.getComputedStyle(content).paddingTop || "0" + ); + const anchorTop = anchor.offsetTop; + const targetScrollTop = Math.max(0, anchorTop - contentPaddingTop); + const requiredSlack = Math.max( + 0, + targetScrollTop - (content.scrollHeight - container.clientHeight) + ); + + spacer.style.height = `${Math.ceil(requiredSlack)}px`; + + if (scrollToAnchor) { + container.scrollTop = targetScrollTop; + } + + updateBottomState(); + return true; + }, + [anchorMessageId, updateBottomState] + ); + useEffect(() => { - if (!scrollContainer) return; + const container = scrollRef.current; + if (!container) return; + + const handleScroll = () => { + updateBottomState(); + }; + + handleScroll(); + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, [updateBottomState]); + + useLayoutEffect(() => { + const container = scrollRef.current; + const content = contentRef.current; + if (!container || !content) return; let rafId: number | null = null; - const checkAndRestoreScroll = () => { - if (!isUserEngagedRef.current) return; - - const currentScrollTop = scrollContainer.scrollTop; - const currentScrollHeight = scrollContainer.scrollHeight; - const savedScrollTop = savedScrollTopRef.current; - - // If scroll position jumped significantly (auto-scroll happened) - // and scroll height also changed (content changed), restore position - if ( - Math.abs(currentScrollTop - savedScrollTop) > 50 && - currentScrollHeight !== lastScrollHeightRef.current - ) { - scrollContainer.scrollTop = savedScrollTop; + const schedule = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); } - - lastScrollHeightRef.current = currentScrollHeight; + rafId = requestAnimationFrame(() => { + applyAnchorLayout(false); + }); }; - // Use ResizeObserver to detect content changes - const resizeObserver = new ResizeObserver(() => { - if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(checkAndRestoreScroll); - }); - - resizeObserver.observe(scrollContainer); + const observer = new ResizeObserver(schedule); + observer.observe(container); + observer.observe(content); + schedule(); return () => { - resizeObserver.disconnect(); - if (rafId) cancelAnimationFrame(rafId); + observer.disconnect(); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } }; - }, [scrollContainer]); + }, [applyAnchorLayout]); + + useLayoutEffect(() => { + if (anchorRequestKey === undefined) return; + + let attempts = 0; + let rafId: number | null = null; + + const tryAnchor = () => { + if (applyAnchorLayout(true)) { + return; + } + if (attempts >= MAX_ANCHOR_RETRIES) { + return; + } + attempts += 1; + rafId = requestAnimationFrame(tryAnchor); + }; + + tryAnchor(); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [anchorRequestKey, applyAnchorLayout]); + + const scrollToBottom = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + updateBottomState(); + }, [updateBottomState]); + + const contextValue = useMemo( + () => ({ + contentRef, + isAtBottom, + scrollRef, + scrollToBottom, + }), + [isAtBottom, scrollToBottom] + ); return ( - - +
- {children} - - +
+ {children} + +
+ ); }; -/** - * Component that tracks scroll engagement and preserves position. - * Must be used inside Conversation component. - */ -export const ScrollPositionPreserver = () => { - const { isAtBottom, scrollRef } = useStickToBottomContext(); - const preservationContext = useContext(ScrollPreservationContext); - const containerFoundRef = useRef(false); +const useConversationContext = () => { + const context = useContext(ConversationContext); - // Find and register scroll container on mount - useLayoutEffect(() => { - if (containerFoundRef.current || !preservationContext) return; + if (!context) { + throw new Error( + "Conversation components must be used within a Conversation component." + ); + } - // Use the local StickToBottom scroll container for this conversation instance. - const container = scrollRef.current; - if (container) { - preservationContext.registerScrollContainer(container); - containerFoundRef.current = true; - } - }, [preservationContext, scrollRef]); - - // Track engagement based on scroll position - useEffect(() => { - if (!preservationContext) return; - - if (!isAtBottom) { - // User is not at bottom - mark as engaged - preservationContext.markUserEngaged(); - } else { - // User is back at bottom - reset - preservationContext.resetEngagement(); - } - }, [isAtBottom, preservationContext]); - - return null; + return context; }; -export type ConversationContentProps = ComponentProps< - typeof StickToBottom.Content ->; +export type ConversationContentProps = ComponentProps<"div">; export const ConversationContent = ({ className, ...props -}: ConversationContentProps) => ( - -); +}: ConversationContentProps) => { + const { contentRef } = useConversationContext(); + + return ( +
+ ); +}; export type ConversationEmptyStateProps = ComponentProps<"div"> & { - title?: string; description?: string; - icon?: React.ReactNode; + icon?: ReactNode; + title?: string; }; export const ConversationEmptyState = ({ + children, className, - title = "No messages yet", description = "Start a conversation to see messages here", icon, - children, + title = "No messages yet", ...props }: ConversationEmptyStateProps) => (
); +export const ScrollPositionPreserver = () => null; + export type ConversationScrollButtonProps = ComponentProps; export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + const { isAtBottom, scrollToBottom } = useConversationContext(); const handleScrollToBottom = useCallback(() => { scrollToBottom(); @@ -199,16 +292,16 @@ export const ConversationScrollButton = ({ !isAtBottom && ( ) ); diff --git a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx index c1470326..9e6a3d3e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx @@ -1,5 +1,6 @@ import { isValidElement, type JSX } from 'react' import { FilePathCard } from './file-path-card' +import { MermaidRenderer } from '@/components/mermaid-renderer' export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { const { children, ...rest } = props @@ -19,6 +20,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { return } } + if ( + typeof childProps.className === 'string' && + childProps.className.includes('language-mermaid') + ) { + const text = typeof childProps.children === 'string' + ? childProps.children.trim() + : '' + if (text) { + return + } + } } // Passthrough for all other code blocks - return children directly diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index 98263434..467547b1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -91,11 +91,12 @@ export type FileMention = { id: string; path: string; // "knowledge/notes.md" displayName: string; // "notes" + lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions) }; export type MentionsContext = { mentions: FileMention[]; - addMention: (path: string, displayName: string) => void; + addMention: (path: string, displayName: string, lineNumber?: number) => void; removeMention: (id: string) => void; clearMentions: () => void; }; @@ -279,13 +280,13 @@ export function PromptInputProvider({ // ----- mentions state (for @ file mentions) const [mentionsList, setMentionsList] = useState([]); - const addMention = useCallback((path: string, displayName: string) => { + const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => { setMentionsList((prev) => { - // Avoid duplicates - if (prev.some((m) => m.path === path)) { + // Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct) + if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) { return prev; } - return [...prev, { id: nanoid(), path, displayName }]; + return [...prev, { id: nanoid(), path, displayName, lineNumber }]; }); }, []); diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index d9453aa1..66feb1c6 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -16,8 +16,8 @@ import { WrenchIcon, XCircleIcon, } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { isValidElement } from "react"; +import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; + const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; try { @@ -37,7 +37,7 @@ const ToolCode = ({ }) => (
@@ -98,24 +98,33 @@ export const ToolHeader = ({
   type,
   state,
   ...props
-}: ToolHeaderProps) => (
-  
-    
- - - {title ?? type.split("-").slice(1).join("-")} - - {getStatusBadge(state)} -
- -
-); +}: ToolHeaderProps) => { + const displayTitle = title ?? type.split("-").slice(1).join("-") + + return ( + +
+ + + {displayTitle} + +
+
+ {getStatusBadge(state)} + +
+
+ ) +}; export type ToolContentProps = ComponentProps; @@ -129,63 +138,88 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => ( /> ); -export type ToolInputProps = ComponentProps<"div"> & { +/* ── Tabbed content (Parameters / Result) ────────────────────────── */ + +export type ToolTabbedContentProps = { input: ToolUIPart["input"]; -}; - -export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( -
-

- Parameters -

-
- -
-
-); - -export type ToolOutputProps = ComponentProps<"div"> & { output: ToolUIPart["output"]; - errorText: ToolUIPart["errorText"]; + errorText?: ToolUIPart["errorText"]; }; -export const ToolOutput = ({ - className, +export const ToolTabbedContent = ({ + input, output, errorText, - ...props -}: ToolOutputProps) => { - if (!(output || errorText)) { - return null; - } +}: ToolTabbedContentProps) => { + const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters"); + const hasOutput = output != null || !!errorText; - let Output =
{output as ReactNode}
; - - if (typeof output === "object" && !isValidElement(output)) { - Output = ; - } else if (typeof output === "string") { - Output = ; + let OutputNode: ReactNode = null; + if (errorText) { + OutputNode = ; + } else if (output != null) { + if (typeof output === "object" && !isValidElement(output)) { + OutputNode = ; + } else if (typeof output === "string") { + OutputNode = ; + } else { + OutputNode =
{output as ReactNode}
; + } } return ( -
-

- {errorText ? "Error" : "Result"} -

-
- {errorText && ( -
- {errorText} +
+ {/* Tabs */} +
+ + +
+ + {/* Tab content */} +
+ {activeTab === "parameters" && ( +
+ +
+ )} + {activeTab === "result" && ( +
+ {hasOutput ? ( +
+ {OutputNode} +
+ ) : ( + (pending...) + )}
)} - {Output}
); diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx index cad6ccbf..a68eb360 100644 --- a/apps/x/apps/renderer/src/components/bases-view.tsx +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -1,9 +1,16 @@ import * as React from 'react' import { useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react' +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' import { Dialog, DialogContent, @@ -91,6 +98,12 @@ type BasesViewProps = { externalSearch?: string /** Called after the external search has been consumed (applied to internal state). */ onExternalSearchConsumed?: () => void + /** Actions for context menu */ + actions?: { + rename: (oldPath: string, newName: string, isDir: boolean) => Promise + remove: (path: string) => Promise + copyPath: (path: string) => void + } } function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { @@ -140,10 +153,15 @@ function getSortValue(note: NoteEntry, column: string): string | number { if (column === 'mtimeMs') return note.mtimeMs const v = note.fields[column] if (!v) return '' + if (column === 'last_update' || column === 'first_met') { + const s = Array.isArray(v) ? v[0] ?? '' : v + const ms = Date.parse(s) + return isNaN(ms) ? 0 : ms + } return Array.isArray(v) ? v[0] ?? '' : v } -export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) { +export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed, actions }: BasesViewProps) { // Build notes instantly from tree const notes = useMemo(() => { return collectFiles(tree).map((f) => ({ @@ -652,22 +670,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul {pageNotes.map((note) => ( - onSelectNote(note.path)} - > - {visibleColumns.map((col) => ( - - - - ))} - + note={note} + visibleColumns={visibleColumns} + filters={filters} + toggleFilter={toggleFilter} + onSelectNote={onSelectNote} + actions={actions} + /> ))} {pageNotes.length === 0 && ( @@ -770,6 +781,17 @@ function CellRenderer({ return {formatDate(note.mtimeMs)} } + // Date-like frontmatter columns — render like Last Modified + if (column === 'last_update' || column === 'first_met') { + const value = note.fields[column] + if (!value || Array.isArray(value)) return null + const ms = Date.parse(value) + if (!isNaN(ms)) { + return {formatDate(ms)} + } + return {value} + } + // Frontmatter column const value = note.fields[column] if (!value) return null @@ -801,6 +823,116 @@ function CellRenderer({ ) } +function NoteRow({ + note, + visibleColumns, + filters, + toggleFilter, + onSelectNote, + actions, +}: { + note: NoteEntry + visibleColumns: string[] + filters: ActiveFilter[] + toggleFilter: (category: string, value: string) => void + onSelectNote: (path: string) => void + actions?: BasesViewProps['actions'] +}) { + const [isRenaming, setIsRenaming] = useState(false) + const [newName, setNewName] = useState('') + const isSubmittingRef = useRef(false) + const inputRef = useRef(null) + + useEffect(() => { + if (isRenaming) inputRef.current?.focus() + }, [isRenaming]) + + const baseName = note.name + const handleRenameSubmit = useCallback(async () => { + if (isSubmittingRef.current) return + const trimmed = newName.trim() + if (!trimmed || trimmed === baseName) { + setIsRenaming(false) + return + } + isSubmittingRef.current = true + try { + await actions?.rename(note.path, trimmed, false) + } catch { + // ignore + } + setIsRenaming(false) + isSubmittingRef.current = false + }, [newName, baseName, actions, note.path]) + + const handleCopyPath = useCallback(() => { + actions?.copyPath(note.path) + }, [actions, note.path]) + + const handleDelete = useCallback(() => { + void actions?.remove(note.path) + }, [actions, note.path]) + + const row = ( + onSelectNote(note.path)} + > + {visibleColumns.map((col) => ( + + {col === 'name' && isRenaming ? ( + setNewName(e.target.value)} + onBlur={() => void handleRenameSubmit()} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleRenameSubmit() + if (e.key === 'Escape') setIsRenaming(false) + }} + onClick={(e) => e.stopPropagation()} + className="w-full bg-transparent text-sm font-medium outline-none ring-1 ring-ring rounded px-1" + /> + ) : ( + + )} + + ))} + + ) + + if (!actions) return row + + return ( + + + {row} + + + + + Copy Path + + + { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}> + + Rename + + + + Delete + + + + ) +} + function CategoryBadge({ category, value, diff --git a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx new file mode 100644 index 00000000..8777c035 --- /dev/null +++ b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx @@ -0,0 +1,418 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react' + +import { TabBar } from '@/components/tab-bar' +import { cn } from '@/lib/utils' + +/** + * Embedded browser pane. + * + * Renders a transparent placeholder div whose bounds are reported to the + * main process via `browser:setBounds`. The actual browsing surface is an + * Electron WebContentsView layered on top of the renderer by the main + * process — this component only owns the chrome (tabs, address bar, nav + * buttons) and the sizing/visibility lifecycle. + */ + +interface BrowserTabState { + id: string + url: string + title: string + canGoBack: boolean + canGoForward: boolean + loading: boolean +} + +interface BrowserState { + activeTabId: string | null + tabs: BrowserTabState[] +} + +const EMPTY_STATE: BrowserState = { + activeTabId: null, + tabs: [], +} + +const CHROME_HEIGHT = 40 +const BLOCKING_OVERLAY_SLOTS = new Set([ + 'alert-dialog-content', + 'context-menu-content', + 'context-menu-sub-content', + 'dialog-content', + 'dropdown-menu-content', + 'dropdown-menu-sub-content', + 'hover-card-content', + 'popover-content', + 'select-content', + 'sheet-content', +]) + +interface BrowserPaneProps { + onClose: () => void +} + +const getActiveTab = (state: BrowserState) => + state.tabs.find((tab) => tab.id === state.activeTabId) ?? null + +const isVisibleOverlayElement = (el: HTMLElement) => { + const style = window.getComputedStyle(el) + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false + } + const rect = el.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 +} + +const hasBlockingOverlay = (doc: Document) => { + const openContent = doc.querySelectorAll('[data-slot][data-state="open"]') + return Array.from(openContent).some((el) => { + const slot = el.dataset.slot + if (!slot || !BLOCKING_OVERLAY_SLOTS.has(slot)) return false + return isVisibleOverlayElement(el) + }) +} + +const getBrowserTabTitle = (tab: BrowserTabState) => { + const title = tab.title.trim() + if (title) return title + const url = tab.url.trim() + if (!url) return 'New tab' + try { + const parsed = new URL(url) + return parsed.hostname || parsed.href + } catch { + return url.replace(/^https?:\/\//i, '') || 'New tab' + } +} + +export function BrowserPane({ onClose }: BrowserPaneProps) { + const [state, setState] = useState(EMPTY_STATE) + const [addressValue, setAddressValue] = useState('') + + const activeTabIdRef = useRef(null) + const addressFocusedRef = useRef(false) + const viewportRef = useRef(null) + const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null) + const viewVisibleRef = useRef(false) + + const activeTab = getActiveTab(state) + + const applyState = useCallback((next: BrowserState) => { + const previousActiveTabId = activeTabIdRef.current + activeTabIdRef.current = next.activeTabId + setState(next) + + const nextActiveTab = getActiveTab(next) + if (!addressFocusedRef.current || next.activeTabId !== previousActiveTabId) { + setAddressValue(nextActiveTab?.url ?? '') + } + }, []) + + useEffect(() => { + const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => { + applyState(incoming as BrowserState) + }) + + void window.ipc.invoke('browser:getState', null).then((initial) => { + applyState(initial as BrowserState) + }) + + return cleanup + }, [applyState]) + + const setViewVisible = useCallback((visible: boolean) => { + if (viewVisibleRef.current === visible) return + viewVisibleRef.current = visible + void window.ipc.invoke('browser:setVisible', { visible }) + }, []) + + const measureBounds = useCallback(() => { + const el = viewportRef.current + if (!el) return null + + const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01) + const rect = el.getBoundingClientRect() + const chatSidebar = el.ownerDocument.querySelector('[data-chat-sidebar-root]') + const chatSidebarRect = chatSidebar?.getBoundingClientRect() + const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0 + ? Math.min(rect.right, chatSidebarRect.left) + : rect.right + + // `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's + // native view bounds are in unzoomed window coordinates, so convert back + // using the renderer zoom factor before calling into the main process. + const left = Math.ceil(rect.left * zoomFactor) + const top = Math.ceil(rect.top * zoomFactor) + const right = Math.floor(clampedRightCss * zoomFactor) + const bottom = Math.floor(rect.bottom * zoomFactor) + const width = right - left + const height = bottom - top + + if (width <= 0 || height <= 0) return null + + return { + x: left, + y: top, + width, + height, + } + }, []) + + const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => { + const last = lastBoundsRef.current + if ( + last && + last.x === bounds.x && + last.y === bounds.y && + last.width === bounds.width && + last.height === bounds.height + ) { + return bounds + } + lastBoundsRef.current = bounds + void window.ipc.invoke('browser:setBounds', bounds) + return bounds + }, []) + + const syncView = useCallback(() => { + const doc = viewportRef.current?.ownerDocument + if (doc && hasBlockingOverlay(doc)) { + lastBoundsRef.current = null + setViewVisible(false) + return null + } + + const bounds = measureBounds() + if (!bounds) { + lastBoundsRef.current = null + setViewVisible(false) + return null + } + pushBounds(bounds) + setViewVisible(true) + return bounds + }, [measureBounds, pushBounds, setViewVisible]) + + useEffect(() => { + syncView() + }, [activeTab?.id, activeTab?.loading, activeTab?.url, syncView]) + + useEffect(() => { + let cancelled = false + const rafId = requestAnimationFrame(() => { + if (cancelled) return + syncView() + }) + return () => { + cancelled = true + cancelAnimationFrame(rafId) + lastBoundsRef.current = null + setViewVisible(false) + } + }, [setViewVisible, syncView]) + + useEffect(() => { + const el = viewportRef.current + if (!el) return + + const sidebarInset = el.closest('[data-slot="sidebar-inset"]') + const chatSidebar = el.ownerDocument.querySelector('[data-chat-sidebar-root]') + const documentElement = el.ownerDocument.documentElement + + let pendingRaf: number | null = null + const schedule = () => { + if (pendingRaf !== null) return + pendingRaf = requestAnimationFrame(() => { + pendingRaf = null + syncView() + }) + } + + const ro = new ResizeObserver(schedule) + ro.observe(el) + if (sidebarInset) ro.observe(sidebarInset) + if (chatSidebar) ro.observe(chatSidebar) + ro.observe(documentElement) + + return () => { + if (pendingRaf !== null) cancelAnimationFrame(pendingRaf) + ro.disconnect() + } + }, [syncView]) + + useEffect(() => { + const doc = viewportRef.current?.ownerDocument + if (!doc?.body) return + + let pendingRaf: number | null = null + const schedule = () => { + if (pendingRaf !== null) return + pendingRaf = requestAnimationFrame(() => { + pendingRaf = null + syncView() + }) + } + + const observer = new MutationObserver(schedule) + observer.observe(doc.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['data-state', 'style', 'hidden', 'aria-hidden', 'open'], + }) + + return () => { + if (pendingRaf !== null) cancelAnimationFrame(pendingRaf) + observer.disconnect() + } + }, [syncView]) + + const handleNewTab = useCallback(() => { + void window.ipc.invoke('browser:newTab', {}).then((res) => { + const result = res as { ok: boolean; error?: string } + if (!result.ok && result.error) { + console.error('browser:newTab failed', result.error) + } + }) + }, []) + + const handleSwitchTab = useCallback((tabId: string) => { + void window.ipc.invoke('browser:switchTab', { tabId }) + }, []) + + const handleCloseTab = useCallback((tabId: string) => { + void window.ipc.invoke('browser:closeTab', { tabId }) + }, []) + + const handleSubmitAddress = useCallback((e: React.FormEvent) => { + e.preventDefault() + const trimmed = addressValue.trim() + if (!trimmed) return + void window.ipc.invoke('browser:navigate', { url: trimmed }).then((res) => { + const result = res as { ok: boolean; error?: string } + if (!result.ok && result.error) { + console.error('browser:navigate failed', result.error) + } + }) + }, [addressValue]) + + const handleBack = useCallback(() => { + void window.ipc.invoke('browser:back', null) + }, []) + + const handleForward = useCallback(() => { + void window.ipc.invoke('browser:forward', null) + }, []) + + const handleReload = useCallback(() => { + void window.ipc.invoke('browser:reload', null) + }, []) + + return ( +
+
+ tab.id} + onSwitchTab={handleSwitchTab} + onCloseTab={handleCloseTab} + layout="scroll" + /> + +
+ +
+ + + +
+ setAddressValue(e.target.value)} + onFocus={(e) => { + addressFocusedRef.current = true + e.currentTarget.select() + }} + onBlur={() => { + addressFocusedRef.current = false + setAddressValue(activeTab?.url ?? '') + }} + placeholder="Enter URL or search..." + className={cn( + 'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground', + 'placeholder:text-muted-foreground/60', + 'focus:border-border focus:outline-hidden', + )} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + /> +
+ +
+ +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0c041351..37d8d053 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -266,7 +266,7 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) - // Check search tool availability (brave or exa, or signed-in via gateway) + // Check search tool availability (exa or signed-in via gateway) useEffect(() => { const checkSearch = async () => { if (isRowboatConnected) { @@ -275,17 +275,10 @@ function ChatInputInner({ } let available = false try { - const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' }) + const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) const config = JSON.parse(raw.data) if (config.apiKey) available = true } catch { /* not configured */ } - if (!available) { - try { - const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) - const config = JSON.parse(raw.data) - if (config.apiKey) available = true - } catch { /* not configured */ } - } setSearchAvailable(available) } checkSearch() @@ -570,7 +563,7 @@ function ChatInputInner({ 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" > - {configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || 'Model'} + {configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index ac7f23be..e51d7c8f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -8,7 +8,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation' import { Message, @@ -16,8 +16,9 @@ import { MessageResponse, } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' -import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' +import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' @@ -29,11 +30,14 @@ import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat- import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { + type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, type PermissionResponse, createEmptyChatTabViewState, getWebSearchCardData, + getComposioConnectCardData, + getToolDisplayName, isChatMessage, isErrorMessage, isToolCall, @@ -45,6 +49,60 @@ import { const streamdownComponents = { pre: MarkdownPreOverride } +/* ─── Billing error helpers ─── */ + +const BILLING_ERROR_PATTERNS = [ + { + pattern: /upgrade required/i, + title: 'A subscription is required', + subtitle: 'Get started with a plan to access AI features in Rowboat.', + cta: 'Subscribe', + }, + { + pattern: /not enough credits/i, + title: 'You\'ve run out of credits', + subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.', + cta: 'Upgrade plan', + }, + { + pattern: /subscription not active/i, + title: 'Your subscription is inactive', + subtitle: 'Reactivate your subscription to continue using AI features.', + cta: 'Reactivate', + }, +] as const + +function matchBillingError(message: string) { + return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null +} + +interface BillingRowboatAccount { + config?: { + appUrl?: string | null + } | null +} + +function BillingErrorCTA({ label }: { label: string }) { + const [appUrl, setAppUrl] = useState(null) + + useEffect(() => { + window.ipc.invoke('account:getRowboat', null) + .then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null)) + .catch(() => {}) + }, []) + + if (!appUrl) return null + + return ( + + ) +} + const MIN_WIDTH = 360 const MAX_WIDTH = 1600 const MIN_MAIN_PANE_WIDTH = 420 @@ -87,6 +145,7 @@ interface ChatSidebarProps { conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record + viewportAnchors?: Record isProcessing: boolean isStopping?: boolean onStop?: () => void @@ -121,6 +180,7 @@ interface ChatSidebarProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onComposioConnected?: (toolkitSlug: string) => void } export function ChatSidebar({ @@ -138,6 +198,7 @@ export function ChatSidebar({ conversation, currentAssistantMessage, chatTabStates = {}, + viewportAnchors = {}, isProcessing, isStopping, onStop, @@ -171,6 +232,7 @@ export function ChatSidebar({ ttsMode, onToggleTts, onTtsModeChange, + onComposioConnected, }: ChatSidebarProps) { const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [isResizing, setIsResizing] = useState(false) @@ -284,7 +346,7 @@ export function ChatSidebar({ if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -296,7 +358,7 @@ export function ChatSidebar({ } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
@@ -316,7 +378,7 @@ export function ChatSidebar({ ) } return ( - + {item.content} @@ -337,6 +399,21 @@ export function ChatSidebar({ /> ) } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) @@ -346,18 +423,31 @@ export function ChatSidebar({ open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} > - + - - {output !== null ? : null} + ) } if (isErrorMessage(item)) { + const billingError = matchBillingError(item.message) + if (billingError) { + return ( + + +
+

{billingError.title}

+

{billingError.subtitle}

+ +
+
+
+ ) + } return ( - +
{item.message}
@@ -383,6 +473,7 @@ export function ChatSidebar({ return (
- - + > + {!tabHasConversation ? ( @@ -526,10 +620,11 @@ export function ChatSidebar({ )} - )} - - -
+ )} + + + +
) })}
diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index e28f662e..92b13a48 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState } from "react" -import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react" +import { AlertTriangle, Loader2, Mic, Mail, Calendar, User } from "lucide-react" import { Popover, @@ -15,7 +15,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { Button } from "@/components/ui/button" -import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" @@ -126,8 +125,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha // Check if Gmail is unconnected (for filtering in unconnected mode) const isGmailUnconnected = c.useComposioForGoogle ? !c.gmailConnected && !c.gmailLoading : true const isGoogleCalendarUnconnected = c.useComposioForGoogleCalendar ? !c.googleCalendarConnected && !c.googleCalendarLoading : true - const isGranolaUnconnected = !c.granolaEnabled && !c.granolaLoading - const isSlackUnconnected = !c.slackEnabled && !c.slackLoading // For unconnected mode, check if there's anything to show const hasUnconnectedEmailCalendar = (() => { @@ -143,7 +140,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const hasUnconnectedMeetingNotes = (() => { if (!isUnconnectedMode) return true - if (isGranolaUnconnected) return true if (c.providers.includes('fireflies-ai')) { const firefliesState = c.providerStates['fireflies-ai'] if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true @@ -151,15 +147,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha return false })() - const hasUnconnectedSlack = !isUnconnectedMode || isSlackUnconnected - const isRowboatUnconnected = (() => { if (!c.providers.includes('rowboat')) return false const rowboatState = c.providerStates['rowboat'] return !rowboatState?.isConnected || rowboatState?.isLoading })() - const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes && !hasUnconnectedSlack + const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes return ( <> @@ -357,128 +351,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha Meeting Notes
- {/* Granola - show in unconnected mode only if not enabled */} - {(!isUnconnectedMode || isGranolaUnconnected) && ( -
-
-
- -
-
- Granola - - Local meeting notes - -
-
-
- {c.granolaLoading && ( - - )} - -
-
- )} - {/* Fireflies */} {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} )} - - {/* Team Communication Section */} - {hasUnconnectedSlack && ( - <> -
- Team Communication -
- -
-
-
-
- -
-
- Slack - {c.slackEnabled && c.slackWorkspaces.length > 0 ? ( - - {c.slackWorkspaces.map(w => w.name).join(', ')} - - ) : ( - - Send messages and view channels - - )} -
-
-
- {(c.slackLoading || c.slackDiscovering) && ( - - )} - {c.slackEnabled ? ( - c.handleSlackDisable()} - disabled={c.slackLoading} - /> - ) : ( - - )} -
-
- {c.slackPickerOpen && ( -
- {c.slackDiscoverError ? ( -

{c.slackDiscoverError}

- ) : ( - <> - {c.slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
- - )} )}
diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx index 3ef536d9..14e94339 100644 --- a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -17,7 +17,7 @@ const GOOGLE_CLIENT_ID_SETUP_GUIDE_URL = interface GoogleClientIdModalProps { open: boolean onOpenChange: (open: boolean) => void - onSubmit: (clientId: string) => void + onSubmit: (clientId: string, clientSecret: string) => void isSubmitting?: boolean description?: string } @@ -30,19 +30,22 @@ export function GoogleClientIdModal({ description, }: GoogleClientIdModalProps) { const [clientId, setClientId] = useState("") + const [clientSecret, setClientSecret] = useState("") useEffect(() => { if (!open) { setClientId("") + setClientSecret("") } }, [open]) const trimmedClientId = clientId.trim() - const isValid = trimmedClientId.length > 0 + const trimmedClientSecret = clientSecret.trim() + const isValid = trimmedClientId.length > 0 && trimmedClientSecret.length > 0 const handleSubmit = () => { if (!isValid || isSubmitting) return - onSubmit(trimmedClientId) + onSubmit(trimmedClientId, trimmedClientSecret) } return ( @@ -50,9 +53,9 @@ export function GoogleClientIdModal({
- Google Client ID + Google OAuth Credentials - {description ?? "Enter the client ID for your Google OAuth app to connect."} + {description ?? "Enter the credentials for your Google OAuth app to connect."}
@@ -76,6 +79,25 @@ export function GoogleClientIdModal({ autoFocus />
+
+ + setClientSecret(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + className="font-mono text-xs" + /> +

Need help?{" "} (null) const positionsRef = useRef>(new Map()) const motionSeedsRef = useRef>(new Map()) @@ -456,22 +456,13 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap return (

- {isLoading ? ( -
-
- - Building graph… -
-
- ) : null} - - {error ? ( +{error ? (
{error}
) : null} - {!isLoading && !error && nodes.length === 0 ? ( + {!error && nodes.length === 0 ? (
No notes found.
diff --git a/apps/x/apps/renderer/src/components/help-popover.tsx b/apps/x/apps/renderer/src/components/help-popover.tsx index fdabacc0..3ff08a5c 100644 --- a/apps/x/apps/renderer/src/components/help-popover.tsx +++ b/apps/x/apps/renderer/src/components/help-popover.tsx @@ -25,7 +25,7 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) { const [open, setOpen] = useState(false) const handleDiscordClick = () => { - window.open("https://discord.gg/htdKpBZF", "_blank") + window.open("https://discord.com/invite/wajrgmJQ6b", "_blank") } return ( diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 2592dec3..e97f7c6e 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -7,16 +7,24 @@ import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' +import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table' +import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' +import { TrackBlockExtension } from '@/extensions/track-block' +import { PromptBlockExtension } from '@/extensions/prompt-block' +import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' +import { IframeBlockExtension } from '@/extensions/iframe-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' import { EmailBlockExtension } from '@/extensions/email-block' +import { TranscriptBlockExtension } from '@/extensions/transcript-block' +import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' -import { useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' // Zero-width space used as invisible marker for blank lines @@ -40,6 +48,36 @@ function preprocessMarkdown(markdown: string): string { }) } +// Convert track-target open/close HTML comment markers into placeholder divs +// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom +// nodes. Content *between* the markers is left untouched — tiptap-markdown +// parses it naturally as whatever it is (paragraphs, lists, custom-block +// fences, etc.), all rendered live by the existing extension set. +// +// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag +// line until a blank line terminates it, and markdown inline rules (bold, +// italics, links) don't apply inside the block. Without surrounding blank +// lines, the line right after our placeholder div gets absorbed as HTML and +// its markdown is not parsed. +// +// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n` +// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks +// on save; a `\n?` regex on reload would only consume one of those two +// newlines, so every cycle would add a net newline on each side of every +// marker — causing tracks running on an open note to steadily inflate the +// file with blank lines around target regions. +function preprocessTrackTargets(md: string): string { + return md + .replace( + /\n*\n*/g, + (_m, id: string) => `\n\n
\n\n`, + ) + .replace( + /\n*\n*/g, + (_m, id: string) => `\n\n
\n\n`, + ) +} + // Post-process to clean up any zero-width spaces in the output function postprocessMarkdown(markdown: string): string { // Remove lines that contain only the zero-width space marker @@ -52,151 +90,244 @@ function postprocessMarkdown(markdown: string): string { }).join('\n') } -// Custom function to get markdown that preserves empty paragraphs as blank lines -function getMarkdownWithBlankLines(editor: Editor): string { - const json = editor.getJSON() - if (!json.content) return '' +type JsonNode = { + type?: string + content?: JsonNode[] + text?: string + marks?: Array<{ type: string; attrs?: Record }> + attrs?: Record +} - const blocks: string[] = [] - - // Helper to convert a node to markdown text - const nodeToText = (node: { - type?: string - content?: Array<{ - type?: string - text?: string - marks?: Array<{ type: string; attrs?: Record }> - attrs?: Record - }> - attrs?: Record - }): string => { - if (!node.content) return '' - return node.content.map(child => { - if (child.type === 'text') { - let text = child.text || '' - // Apply marks (bold, italic, etc.) - if (child.marks) { - for (const mark of child.marks) { - if (mark.type === 'bold') text = `**${text}**` - else if (mark.type === 'italic') text = `*${text}*` - else if (mark.type === 'code') text = `\`${text}\`` - else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` - } +// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text +function nodeToText(node: JsonNode): string { + if (!node.content) return '' + return node.content.map(child => { + if (child.type === 'text') { + let text = child.text || '' + if (child.marks) { + for (const mark of child.marks) { + if (mark.type === 'bold') text = `**${text}**` + else if (mark.type === 'italic') text = `*${text}*` + else if (mark.type === 'code') text = `\`${text}\`` + else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` } - return text - } else if (child.type === 'wikiLink') { - const path = (child.attrs?.path as string) || '' - return path ? `[[${path}]]` : '' - } else if (child.type === 'hardBreak') { - return '\n' } - return '' - }).join('') - } + return text + } else if (child.type === 'wikiLink') { + const path = (child.attrs?.path as string) || '' + return path ? `[[${path}]]` : '' + } else if (child.type === 'hardBreak') { + return '\n' + } + return '' + }).join('') +} - for (const node of json.content) { - if (node.type === 'paragraph') { - const text = nodeToText(node) - // If the paragraph contains only the blank line marker or is empty, it's a blank line - if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) { - // Push empty string to represent blank line - will add extra newline when joining - blocks.push('') +// Recursively serialize a list node (one line per item; nested lists indented two spaces) +function serializeList(listNode: JsonNode, indent: number): string[] { + const lines: string[] = [] + const items = (listNode.content || []) as JsonNode[] + items.forEach((item, index) => { + const indentStr = ' '.repeat(indent) + let prefix: string + if (listNode.type === 'taskList') { + const checked = item.attrs?.checked ? 'x' : ' ' + prefix = `- [${checked}] ` + } else if (listNode.type === 'orderedList') { + prefix = `${index + 1}. ` + } else { + prefix = '- ' + } + const itemContent = (item.content || []) as JsonNode[] + let firstPara = true + itemContent.forEach(child => { + if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { + lines.push(...serializeList(child, indent + 1)) } else { - blocks.push(text) + const text = nodeToText(child) + if (firstPara) { + lines.push(indentStr + prefix + text) + firstPara = false + } else { + lines.push(indentStr + ' ' + text) + } } - } else if (node.type === 'heading') { - const level = (node.attrs?.level as number) || 1 + }) + }) + return lines +} + +// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is +// actually invoked — the other helpers are stubs to satisfy the type. +const tableRenderHelpers: MarkdownRendererHelpers = { + renderChildren: (nodes) => { + const arr = Array.isArray(nodes) ? nodes : [nodes] + return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('') + }, + wrapInBlock: (prefix, content) => prefix + content, + indent: (content) => content, +} + +// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker +// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown. +function blockToMarkdown(node: JsonNode): string { + switch (node.type) { + case 'paragraph': { const text = nodeToText(node) - blocks.push('#'.repeat(level) + ' ' + text) - } else if (node.type === 'bulletList' || node.type === 'orderedList') { - // Handle lists - all items are part of one block - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - listItems.forEach((item, index) => { - const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- ' - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { - const text = nodeToText(para) - if (paraIndex === 0) { - listLines.push(prefix + text) - } else { - listLines.push(' ' + text) - } - }) - }) - blocks.push(listLines.join('\n')) - } else if (node.type === 'taskList') { - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - listItems.forEach(item => { - const checked = item.attrs?.checked ? 'x' : ' ' - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { - const text = nodeToText(para) - if (paraIndex === 0) { - listLines.push(`- [${checked}] ${text}`) - } else { - listLines.push(' ' + text) - } - }) - }) - blocks.push(listLines.join('\n')) - } else if (node.type === 'taskBlock') { - blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'imageBlock') { - blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'embedBlock') { - blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'chartBlock') { - blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'tableBlock') { - blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'calendarBlock') { - blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'emailBlock') { - blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'codeBlock') { + if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return '' + return text + } + case 'heading': { + const level = (node.attrs?.level as number) || 1 + return '#'.repeat(level) + ' ' + nodeToText(node) + } + case 'bulletList': + case 'orderedList': + case 'taskList': + return serializeList(node, 0).join('\n') + case 'taskBlock': + return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'promptBlock': + return '```prompt\n' + (node.attrs?.data as string || '') + '\n```' + case 'trackBlock': + return '```track\n' + (node.attrs?.data as string || '') + '\n```' + case 'trackTargetOpen': + return `` + case 'trackTargetClose': + return `` + case 'imageBlock': + return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'embedBlock': + return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'iframeBlock': + return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'chartBlock': + return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'tableBlock': + return '```table\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'calendarBlock': + return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'emailBlock': + return '```email\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'transcriptBlock': + return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'mermaidBlock': + return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```' + case 'table': + return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim() + case 'codeBlock': { const lang = (node.attrs?.language as string) || '' - blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') - } else if (node.type === 'blockquote') { - const content = node.content || [] - const quoteLines = content.map(para => '> ' + nodeToText(para)) - blocks.push(quoteLines.join('\n')) - } else if (node.type === 'horizontalRule') { - blocks.push('---') - } else if (node.type === 'wikiLink') { + return '```' + lang + '\n' + nodeToText(node) + '\n```' + } + case 'blockquote': { + const content = (node.content || []) as JsonNode[] + return content.map(para => '> ' + nodeToText(para)).join('\n') + } + case 'horizontalRule': + return '---' + case 'wikiLink': { const path = (node.attrs?.path as string) || '' - blocks.push(`[[${path}]]`) - } else if (node.type === 'image') { + return `[[${path}]]` + } + case 'image': { const src = (node.attrs?.src as string) || '' const alt = (node.attrs?.alt as string) || '' - blocks.push(`![${alt}](${src})`) + return `![${alt}](${src})` } + default: + return '' } +} - // Custom join: content blocks get \n\n before them, empty blocks add \n each - // This produces: 1 empty paragraph = 3 newlines (1 blank line on disk) +// Pure helper: serialize a slice of top-level block nodes to markdown. +// Custom join: content blocks get \n\n before them, empty blocks add \n each. +// 1 empty paragraph = 3 newlines on disk (1 blank line). +function serializeBlocksToMarkdown(blocks: JsonNode[]): string { if (blocks.length === 0) return '' - let result = '' - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i] + const block = blockToMarkdown(blocks[i]) const isContent = block !== '' - if (i === 0) { result = block } else if (isContent) { - // Content block: add \n\n before it (standard paragraph break) result += '\n\n' + block } else { - // Empty block: just add \n (one extra newline for blank line) result += '\n' } } - return result } + +// Custom function to get markdown that preserves empty paragraphs as blank lines +function getMarkdownWithBlankLines(editor: Editor): string { + const json = editor.getJSON() as JsonNode + if (!json.content) return '' + return serializeBlocksToMarkdown(json.content as JsonNode[]) +} + +// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines +// would produce. Used to attach precise line-references when inserting editor-context mentions. +function getCursorContextLine(editor: Editor): number { + const $from = editor.state.selection.$from + const json = editor.getJSON() as JsonNode + const blocks = (json.content ?? []) as JsonNode[] + if (blocks.length === 0) return 1 + + const blockIndex = $from.index(0) + if (blockIndex < 0 || blockIndex >= blocks.length) return 1 + + // Line where the cursor's top-level block starts. + // Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line. + let blockStartLine: number + if (blockIndex === 0) { + blockStartLine = 1 + } else { + const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex)) + const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length + blockStartLine = prefixLineCount + 2 + } + + return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from) +} + +// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading); +// for multi-line containers, computed against how the block serializes. +function computeWithinBlockOffset( + block: JsonNode, + $from: { parentOffset: number; depth: number; index: (depth: number) => number } +): number { + switch (block.type) { + case 'paragraph': + case 'heading': { + // Each hardBreak before the cursor moves us down one rendered line. + const offset = $from.parentOffset + let pos = 0 + let hbCount = 0 + for (const child of (block.content ?? [])) { + if (pos >= offset) break + const size = child.type === 'text' ? (child.text?.length ?? 0) : 1 + if (child.type === 'hardBreak' && pos < offset) hbCount++ + pos += size + } + return hbCount + } + case 'bulletList': + case 'orderedList': + case 'taskList': + case 'blockquote': + // Item index within the container = lines into the block (one item per line for shallow lists/quotes). + return $from.depth >= 1 ? $from.index(1) : 0 + case 'codeBlock': { + // +1 for the opening ``` fence line, plus newlines within the code text before the cursor. + const text = block.content?.[0]?.text ?? '' + const before = text.substring(0, $from.parentOffset) + return 1 + (before.match(/\n/g)?.length ?? 0) + } + default: + return 0 + } +} import { EditorToolbar } from './editor-toolbar' import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' @@ -428,7 +559,12 @@ const TabIndentExtension = Extension.create({ }, }) -export function MarkdownEditor({ +export interface MarkdownEditorHandle { + /** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */ + getCursorContext: () => { path: string; lineNumber: number } | null +} + +export const MarkdownEditor = forwardRef(function MarkdownEditor({ content, onChange, onPrimaryHeadingCommit, @@ -443,7 +579,7 @@ export function MarkdownEditor({ onFrontmatterChange, onExport, notePath, -}: MarkdownEditorProps) { +}, ref) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) const [activeWikiLink, setActiveWikiLink] = useState(null) @@ -561,12 +697,19 @@ export function MarkdownEditor({ }), ImageUploadPlaceholderExtension, TaskBlockExtension, + TrackBlockExtension.configure({ notePath }), + PromptBlockExtension.configure({ notePath }), + TrackTargetOpenExtension, + TrackTargetCloseExtension, ImageBlockExtension, EmbedBlockExtension, + IframeBlockExtension, ChartBlockExtension, TableBlockExtension, CalendarBlockExtension, EmailBlockExtension, + TranscriptBlockExtension, + MermaidBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -578,6 +721,9 @@ export function MarkdownEditor({ TaskItem.configure({ nested: true, }), + TableKit.configure({ + table: { resizable: false }, + }), Placeholder.configure({ placeholder, }), @@ -776,6 +922,17 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + useImperativeHandle(ref, () => ({ + getCursorContext: () => { + if (!notePath || !editor) return null + try { + return { path: notePath, lineNumber: getCursorContextLine(editor) } + } catch { + return null + } + }, + }), [notePath, editor]) + const updateRowboatMentionState = useCallback(() => { if (!editor) return const { selection } = editor.state @@ -942,8 +1099,9 @@ export function MarkdownEditor({ const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - // Pre-process to preserve blank lines - const preprocessed = preprocessMarkdown(content) + // Pre-process to preserve blank lines, then wrap track-target comment + // regions into placeholder divs so TrackTargetExtension can pick them up. + const preprocessed = preprocessMarkdown(preprocessTrackTargets(content)) // Treat tab-open content as baseline: do not add hydration to undo history. editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false @@ -1439,4 +1597,4 @@ export function MarkdownEditor({
) -} +}) diff --git a/apps/x/apps/renderer/src/components/mermaid-renderer.tsx b/apps/x/apps/renderer/src/components/mermaid-renderer.tsx new file mode 100644 index 00000000..db42df2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/mermaid-renderer.tsx @@ -0,0 +1,89 @@ +import { useEffect, useId, useRef, useState } from 'react' +import mermaid from 'mermaid' +import { useTheme } from '@/contexts/theme-context' + +let lastTheme: string | null = null + +function ensureInit(theme: 'default' | 'dark') { + if (lastTheme === theme) return + mermaid.initialize({ + startOnLoad: false, + theme, + securityLevel: 'strict', + }) + lastTheme = theme +} + +interface MermaidRendererProps { + source: string + className?: string +} + +export function MermaidRenderer({ source, className }: MermaidRendererProps) { + const { resolvedTheme } = useTheme() + const id = useId().replace(/:/g, '-') + const containerRef = useRef(null) + const [svg, setSvg] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!source.trim()) { + setSvg(null) + setError(null) + return + } + + let cancelled = false + const mermaidTheme = resolvedTheme === 'dark' ? 'dark' : 'default' + ensureInit(mermaidTheme) + + mermaid + .render(`mermaid-${id}`, source.trim()) + .then(({ svg: renderedSvg }) => { + if (!cancelled) { + setSvg(renderedSvg) + setError(null) + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setSvg(null) + setError(err instanceof Error ? err.message : 'Failed to render diagram') + } + }) + + return () => { + cancelled = true + } + }, [source, resolvedTheme, id]) + + if (error) { + return ( +
+
+ Invalid mermaid syntax +
+
+          {source}
+        
+
+ ) + } + + if (!svg) { + return ( +
+ Rendering diagram... +
+ ) + } + + return ( +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 82064205..c7f723ac 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -23,7 +23,7 @@ import { } from "@/components/ui/select" import { cn } from "@/lib/utils" import { GoogleClientIdModal } from "@/components/google-client-id-modal" -import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" +import { setGoogleCredentials } from "@/lib/google-credentials-store" import { toast } from "sonner" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" @@ -589,14 +589,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { + const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret }) if (!result.success) { toast.error(result.error || `Failed to connect to ${provider}`) @@ -618,22 +618,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) + setGoogleClientIdOpen(true) return } await startConnect(provider) }, [startConnect]) - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) + const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { + setGoogleCredentials(clientId, clientSecret) setGoogleClientIdOpen(false) - startConnect('google', clientId) + startConnect('google', { clientId, clientSecret }) }, [startConnect]) // Step indicator - dynamic based on path diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx index c01e42ea..2b32309a 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx @@ -8,8 +8,8 @@ interface CompletionStepProps { } export function CompletionStep({ state }: CompletionStepProps) { - const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, googleCalendarConnected, handleComplete } = state - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected + const { connectedProviders, gmailConnected, googleCalendarConnected, handleComplete } = state + const hasConnections = connectedProviders.length > 0 || gmailConnected || googleCalendarConnected return (
@@ -109,28 +109,6 @@ export function CompletionStep({ state }: CompletionStepProps) { Fireflies (Meeting transcripts) )} - {granolaEnabled && ( - - - Granola (Local meeting notes) - - )} - {slackEnabled && ( - - - Slack (Team communication) - - )}
)} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx index b152d567..ea8335de 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx @@ -1,9 +1,8 @@ -import { Loader2, CheckCircle2, ArrowLeft, Calendar } from "lucide-react" +import { Loader2, CheckCircle2, ArrowLeft, Calendar, FileText } from "lucide-react" import { motion } from "motion/react" import { Button } from "@/components/ui/button" -import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" -import { GmailIcon, SlackIcon, FirefliesIcon, GranolaIcon } from "../provider-icons" +import { GmailIcon, FirefliesIcon } from "../provider-icons" import type { OnboardingState, ProviderState } from "../use-onboarding-state" interface ConnectAccountsStepProps { @@ -85,11 +84,6 @@ function ProviderCard({ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { const { providers, providersLoading, providerStates, handleConnect, - granolaEnabled, granolaLoading, handleGranolaToggle, - slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces, - slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen, - slackDiscovering, slackDiscoverError, - handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable, useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail, useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar, handleNext, handleBack, @@ -104,7 +98,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { Connect Your Accounts

- Connect your accounts to give Rowboat context about your work. You can always add more later. + Rowboat gets smarter the more it knows about your work. Connect your accounts to get started. You can find more tools in Settings.

{providersLoading ? ( @@ -122,7 +116,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { {useComposioForGoogle ? ( } iconBg="bg-red-500/10" iconColor="text-red-500" @@ -145,7 +139,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { {useComposioForGoogleCalendar && ( } iconBg="bg-blue-500/10" iconColor="text-blue-500" @@ -162,29 +156,31 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { Meeting Notes - } - iconBg="bg-purple-500/10" - iconColor="text-purple-500" - providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }} - rightSlot={ -
- {granolaLoading && } - + +
+
+
- } - index={cardIndex++} - /> +
+
Rowboat Meeting Notes
+
Built in. Ready to use.
+
+
+
+
+ +
+
+
{providers.includes('fireflies-ai') && ( } iconBg="bg-amber-500/10" iconColor="text-amber-500" @@ -194,83 +190,6 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { /> )}
- - {/* Team Communication */} -
- - Team Communication - -
- 0 - ? slackWorkspaces.map(w => w.name).join(', ') - : "Enable Rowboat to understand your team conversations and provide relevant context" - } - icon={} - iconBg="bg-emerald-500/10" - iconColor="text-emerald-500" - providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }} - rightSlot={ -
- {(slackLoading || slackDiscovering) && } - {slackEnabled ? ( - handleSlackDisable()} - disabled={slackLoading} - /> - ) : ( - - )} -
- } - index={cardIndex++} - /> - {slackPickerOpen && ( -
- {slackDiscoverError ? ( -

{slackDiscoverError}

- ) : ( - <> - {slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
-
)} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index 534a67a8..a9956245 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {

- Tip: Sign in with Rowboat for instant access to all models — no API keys needed. + Tip: Sign in with Rowboat for instant access to leading models. No API keys needed.

+ + Enter to send +
+ )} + {!contextChip && ( +
+ Enter to send +
+ )} +
+ ) : ( + <> + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+ + )} ) } +// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette. +export const SearchDialog = CommandPalette + +function deriveDisplayName(path: string): string { + const base = path.split('/').pop() ?? path + return base.replace(/\.md$/, '') +} + +function ModeButton({ + active, + onClick, + icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string +}) { + return ( + + ) +} + function FilterToggle({ active, onClick, diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 70e3d386..55e86f85 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -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, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug, Sparkles } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, Sparkles } from "lucide-react" import { Dialog, @@ -26,7 +26,7 @@ import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" import { SkillsSettings } from "@/components/settings/skills-settings" -export type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "skills" +export type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging" | "skills" interface TabConfig { id: ConfigTab @@ -76,6 +76,12 @@ const tabs: TabConfig[] = [ icon: Palette, description: "Customize the look and feel", }, + { + id: "tools", + label: "Tools Library", + icon: Wrench, + description: "Browse and enable toolkits", + }, { id: "note-tagging", label: "Note Tagging", @@ -715,6 +721,325 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Tools Library Settings --- + +interface ToolkitInfo { + slug: string + name: string + meta: { description: string; logo: string; tools_count: number; triggers_count: number } + no_auth?: boolean + auth_schemes?: string[] + composio_managed_auth_schemes?: string[] +} + +function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: boolean; rowboatConnected: boolean }) { + // API key state + const [apiKeyConfigured, setApiKeyConfigured] = useState(false) + const [apiKeyInput, setApiKeyInput] = useState("") + const [apiKeySaving, setApiKeySaving] = useState(false) + const [showApiKeyInput, setShowApiKeyInput] = useState(false) + + // Toolkit browsing state + const [toolkits, setToolkits] = useState([]) + const [toolkitsLoading, setToolkitsLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + + // Connection state + const [connectedToolkits, setConnectedToolkits] = useState>(new Set()) + const [connectingToolkit, setConnectingToolkit] = useState(null) + + // Check API key configuration + const checkApiKey = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:is-configured", null) + setApiKeyConfigured(result.configured) + if (!result.configured) { + setShowApiKeyInput(true) + } + } catch { + setApiKeyConfigured(false) + } + }, []) + + // Load connected toolkits + const loadConnected = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:list-connected", null) + setConnectedToolkits(new Set(result.toolkits)) + } catch { + // ignore + } + }, []) + + // Load toolkits + const loadToolkits = useCallback(async () => { + setToolkitsLoading(true) + try { + const result = await window.ipc.invoke("composio:list-toolkits", {}) + setToolkits(result.items) + } catch { + toast.error("Failed to load toolkits") + } finally { + setToolkitsLoading(false) + } + }, []) + + // Initial load + useEffect(() => { + if (!dialogOpen) return + checkApiKey() + loadConnected() + }, [dialogOpen, checkApiKey, loadConnected]) + + // Load toolkits when API key is configured + useEffect(() => { + if (dialogOpen && apiKeyConfigured) { + loadToolkits() + } + }, [dialogOpen, apiKeyConfigured, loadToolkits]) + + // Listen for composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + setConnectingToolkit(null) + if (success) { + setConnectedToolkits(prev => new Set([...prev, toolkitSlug])) + toast.success(`Connected to ${toolkitSlug}`) + } else { + toast.error(error || `Failed to connect to ${toolkitSlug}`) + } + }) + return cleanup + }, []) + + // Save API key + const handleSaveApiKey = async () => { + const trimmed = apiKeyInput.trim() + if (!trimmed) return + setApiKeySaving(true) + try { + const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed }) + if (result.success) { + setApiKeyConfigured(true) + setShowApiKeyInput(false) + setApiKeyInput("") + toast.success("Composio API key saved") + } else { + toast.error(result.error || "Failed to save API key") + } + } catch { + toast.error("Failed to save API key") + } finally { + setApiKeySaving(false) + } + } + + // Connect a toolkit + const handleConnect = async (toolkitSlug: string) => { + setConnectingToolkit(toolkitSlug) + try { + const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug }) + if (!result.success) { + toast.error(result.error || "Failed to connect") + setConnectingToolkit(null) + } + // Success will be handled by composio:didConnect event + } catch { + toast.error("Failed to connect") + setConnectingToolkit(null) + } + } + + // Disconnect a toolkit + const handleDisconnect = async (toolkitSlug: string) => { + try { + await window.ipc.invoke("composio:disconnect", { toolkitSlug }) + setConnectedToolkits(prev => { + const next = new Set(prev) + next.delete(toolkitSlug) + return next + }) + toast.success(`Disconnected from ${toolkitSlug}`) + } catch { + toast.error("Failed to disconnect") + } + } + + // Filter toolkits by search + const filteredToolkits = searchQuery.trim() + ? toolkits.filter(t => + t.name.toLowerCase().includes(searchQuery.toLowerCase()) || + t.slug.toLowerCase().includes(searchQuery.toLowerCase()) || + t.meta.description.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : toolkits + + return ( +
+ {/* Section A: API Key (only in BYOK mode) */} + {!rowboatConnected && ( +
+ Composio API Key + {apiKeyConfigured && !showApiKeyInput ? ( +
+
+ + API key configured +
+ +
+ ) : ( +
+

+ Enter your Composio API key to browse and enable tool integrations. + Get your key from{" "} + + app.composio.dev/settings + +

+
+ setApiKeyInput(e.target.value)} + placeholder="Paste your Composio API key" + onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} + className="flex-1" + /> + + {apiKeyConfigured && ( + + )} +
+
+ )} +
+ )} + + {/* Section B: Toolkit Browser (only when API key configured) */} + {apiKeyConfigured && ( + <> +
+ Available Toolkits +
+ + setSearchQuery(e.target.value)} + placeholder="Search toolkits..." + className="pl-8" + /> +
+
+ + {toolkitsLoading ? ( +
+ + Loading toolkits... +
+ ) : ( +
+ {filteredToolkits.map((toolkit) => { + const isConnected = connectedToolkits.has(toolkit.slug) + const isConnecting = connectingToolkit === toolkit.slug + + return ( +
+
+ {/* Logo */} + {toolkit.meta.logo ? ( + { (e.target as HTMLImageElement).style.display = 'none' }} + /> + ) : ( +
+ +
+ )} + + {/* Name & description */} +
+
+ {toolkit.name} + {isConnected && ( + + Connected + + )} +
+

+ {toolkit.meta.description} +

+
+ + {/* Connect / Disconnect button */} + {isConnected ? ( + + ) : ( + + )} +
+
+ ) + })} + + {filteredToolkits.length === 0 && !toolkitsLoading && ( +
+ {searchQuery ? "No toolkits match your search" : "No toolkits available"} +
+ )} +
+ )} + + )} +
+ ) +} + // --- Rowboat Model Settings (when signed in via Rowboat) --- function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { @@ -851,7 +1176,7 @@ const NOTE_TAG_TYPE_ORDER = [ ] const EMAIL_TAG_TYPE_ORDER = [ - "relationship", "topic", "email-type", "filter", "action", "status", + "relationship", "topic", "email-type", "noise", "action", "status", ] const TAG_TYPE_LABELS: Record = { @@ -859,73 +1184,12 @@ const TAG_TYPE_LABELS: Record = { "relationship-sub": "Relationship Sub-Tags", "topic": "Topic", "email-type": "Email Type", - "filter": "Filter", + "noise": "Noise", "action": "Action", "status": "Status", "source": "Source", } -const DEFAULT_TAGS: TagDef[] = [ - { tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." }, - { tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" }, - { tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." }, - { tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." }, - { tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." }, - { tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." }, - { tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." }, - { tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." }, - { tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." }, - { tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." }, - { tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" }, - { tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." }, - { tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." }, - { tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." }, - { tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." }, - { tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." }, - { tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." }, - { tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." }, - { tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." }, - { tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." }, - { tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." }, - { tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." }, - { tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" }, - { tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." }, - { tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." }, - { tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" }, - { tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." }, - { tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." }, - { tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" }, - { tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." }, - { tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." }, - { tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." }, - { tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." }, - { tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." }, - { tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" }, - { tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" }, - { tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." }, - { tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." }, - { tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." }, - { tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." }, - { tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" }, - { tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." }, - { tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" }, - { tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" }, - { tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" }, - { tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." }, - { tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" }, - { tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" }, - { tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" }, - { tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" }, - { tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" }, - { tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" }, - { tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" }, - { tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" }, - { tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" }, - { tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" }, - { tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" }, - { tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" }, - { tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" }, -] function TagGroupTable({ group, @@ -1056,8 +1320,8 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { setTags(parsed) setOriginalTags(parsed) } catch { - setTags([...DEFAULT_TAGS]) - setOriginalTags([...DEFAULT_TAGS]) + setTags([]) + setOriginalTags([]) } finally { setLoading(false) } @@ -1118,7 +1382,7 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { const isEmailSection = activeSection === "email" const applicability = isEmailSection ? "email" as const : "notes" as const // For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both" - const emailOnlyTypes = ["email-type", "filter"] + const emailOnlyTypes = ["email-type", "noise"] const notesOnlyTypes = ["relationship-sub", "source"] let finalApplicability: "email" | "notes" | "both" = "both" if (emailOnlyTypes.includes(type)) finalApplicability = "email" @@ -1156,11 +1420,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { } }, [tags]) - const handleReset = useCallback(() => { - if (!confirm("Reset all tags to defaults? This will discard your changes.")) return - setTags([...DEFAULT_TAGS]) - }, []) - const toggleGroup = useCallback((type: string) => { setCollapsedGroups(prev => { const next = new Set(prev) @@ -1232,9 +1491,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
- @@ -1248,7 +1504,7 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { export function SettingsDialog({ children, initialTab }: SettingsDialogProps) { const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState(initialTab ?? "models") + const [activeTab, setActiveTab] = useState(initialTab ?? "account") const [content, setContent] = useState("") const [originalContent, setOriginalContent] = useState("") const [loading, setLoading] = useState(false) @@ -1269,7 +1525,6 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) { }) }, [open]) - // Check for skill updates useEffect(() => { if (!open) return window.ipc.invoke('skills:list', null).then((result) => { @@ -1280,14 +1535,13 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) { }) }, [open]) - // Handle initialTab changes (e.g. when opened from sidebar notification) useEffect(() => { if (initialTab && open) { setActiveTab(initialTab) } }, [initialTab, open]) - const visibleTabs = useMemo(() => tabs, []) + const visibleTabs = useMemo(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected]) const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0] const isJsonTab = activeTab === "mcp" || activeTab === "security" @@ -1420,7 +1674,7 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
{/* Content */} -
+
{activeTab === "account" ? ( ) : activeTab === "connected-accounts" ? ( @@ -1435,6 +1689,8 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) { ) : activeTab === "skills" ? ( + ) : activeTab === "tools" ? ( + ) : loading ? (
Loading... diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index 04a1e805..1860305d 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useCallback } from "react" -import { Loader2, User, CreditCard, LogOut } from "lucide-react" +import { Loader2, User, CreditCard, LogOut, ExternalLink } from "lucide-react" import { Button } from "@/components/ui/button" import { AlertDialog, @@ -27,6 +27,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { const [connectionLoading, setConnectionLoading] = useState(true) const [disconnecting, setDisconnecting] = useState(false) const [connecting, setConnecting] = useState(false) + const [appUrl, setAppUrl] = useState(null) const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected) const checkConnection = useCallback(async () => { @@ -48,6 +49,14 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { } }, [dialogOpen, checkConnection]) + useEffect(() => { + if (isRowboatConnected) { + window.ipc.invoke('account:getRowboat', null) + .then((account) => setAppUrl(account.config?.appUrl ?? null)) + .catch(() => {}) + } + }, [isRowboatConnected]) + useEffect(() => { const cleanup = window.ipc.on('oauth:didConnect', (event) => { if (event.provider === 'rowboat') { @@ -153,13 +162,25 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
-

{billing.subscriptionPlan ?? 'Free'} Plan

- {billing.subscriptionStatus && ( +

+ {billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'} +

+ {billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => { + const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) + return ( +

+ Trial · {days === 0 ? 'expires today' : days === 1 ? '1 day left' : `${days} days left`} +

+ ) + })() : billing.subscriptionStatus ? (

{billing.subscriptionStatus}

+ ) : null} + {!billing.subscriptionPlan && ( +

Subscribe to access AI features

)}
-
@@ -170,6 +191,32 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { + {/* Payment Section */} +
+
+ +

Payment

+
+

+ Manage invoices, payment methods, and billing details. +

+ + {!billing?.subscriptionPlan && ( +

Subscribe to a plan first

+ )} +
+ + + {/* Log Out Section */}
diff --git a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx index 1cb12f6e..cdacdc39 100644 --- a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx @@ -1,9 +1,8 @@ "use client" import * as React from "react" -import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, Calendar } from "lucide-react" import { Button } from "@/components/ui/button" -import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" @@ -235,129 +234,18 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti )} {/* Meeting Notes Section */} -
- - Meeting Notes - -
- - {/* Granola */} -
-
-
- -
-
- Granola - - Local meeting notes + {c.providers.includes('fireflies-ai') && ( + <> +
+ + Meeting Notes
-
-
- {c.granolaLoading && ( - - )} - -
-
- {/* Fireflies */} - {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} - - - - {/* Team Communication Section */} -
- - Team Communication - -
- - {/* Slack */} -
-
-
-
- -
-
- Slack - {c.slackEnabled && c.slackWorkspaces.length > 0 ? ( - - {c.slackWorkspaces.map(w => w.name).join(', ')} - - ) : ( - - Send messages and view channels - - )} -
-
-
- {(c.slackLoading || c.slackDiscovering) && ( - - )} - {c.slackEnabled ? ( - c.handleSlackDisable()} - disabled={c.slackLoading} - /> - ) : ( - - )} -
-
- {c.slackPickerOpen && ( -
- {c.slackDiscoverError ? ( -

{c.slackDiscoverError}

- ) : ( - <> - {c.slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
+ {/* Fireflies */} + {renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + )}
) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 5c5131ee..24cdc847 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -10,14 +10,20 @@ import { Copy, ExternalLink, FilePlus, + Folder, FolderPlus, + Globe, AlertTriangle, HelpCircle, Mic, Network, Pencil, + Radio, + SearchIcon, + SquarePen, Table2, Plug, + Lightbulb, LoaderIcon, Settings, Square, @@ -57,6 +63,7 @@ import { SidebarGroupContent, SidebarHeader, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, @@ -89,6 +96,7 @@ import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" import { useBilling } from "@/hooks/useBilling" import { ServiceEvent } from "@x/shared/src/service-events.js" +import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription" import z from "zod" interface TreeNode { @@ -163,6 +171,7 @@ type SidebarContentPanelProps = { selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void + onToggleFolder?: (path: string) => void knowledgeActions: KnowledgeActions onVoiceNoteCreated?: (path: string) => void runs?: RunListItem[] @@ -171,6 +180,16 @@ type SidebarContentPanelProps = { tasksActions?: TasksActions backgroundTasks?: BackgroundTaskItem[] selectedBackgroundTask?: string | null + onNewChat?: () => void + onOpenSearch?: () => void + meetingState?: MeetingTranscriptionState + meetingSummarizing?: boolean + meetingAvailable?: boolean + onToggleMeeting?: () => void + isBrowserOpen?: boolean + onToggleBrowser?: () => void + isSuggestedTopicsOpen?: boolean + onOpenSuggestedTopics?: () => void } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -204,7 +223,7 @@ function formatRunTime(ts: string): string { } function SyncStatusBar() { - const { state, isMobile } = useSidebar() + const { state } = useSidebar() const [activeServices, setActiveServices] = useState>(new Map()) const [popoverOpen, setPopoverOpen] = useState(false) const [logEvents, setLogEvents] = useState([]) @@ -300,7 +319,7 @@ function SyncStatusBar() { return ( <> - {!isMobile && isCollapsed && isSyncing && ( + {isCollapsed && isSyncing && (
(null) const [isRowboatConnected, setIsRowboatConnected] = useState(false) const [loggingIn, setLoggingIn] = useState(false) + const [appUrl, setAppUrl] = useState(null) const { billing } = useBilling(isRowboatConnected) const [skillUpdateCount, setSkillUpdateCount] = useState(0) @@ -427,13 +458,20 @@ export function SidebarContentPanel({ const result = await window.ipc.invoke('oauth:getState', null) const config = result.config || {} const hasError = Object.values(config).some((entry) => Boolean(entry?.error)) + const connected = config['rowboat']?.connected ?? false if (mounted) { setHasOauthError(hasError) - setIsRowboatConnected(config['rowboat']?.connected ?? false) + setIsRowboatConnected(connected) if (!hasError) { setShowOauthAlert(true) } } + if (connected && mounted) { + try { + const account = await window.ipc.invoke('account:getRowboat', null) + if (mounted) setAppUrl(account.config?.appUrl ?? null) + } catch { /* ignore */ } + } } catch (error) { console.error('Failed to fetch OAuth state:', error) if (mounted) { @@ -488,6 +526,89 @@ export function SidebarContentPanel({ ))}
+ {/* Quick action buttons */} +
+ {onNewChat && ( + + )} + {onOpenSearch && ( + + )} + {meetingAvailable && onToggleMeeting && ( + + )} + {onToggleBrowser && ( + + )} + {onOpenSuggestedTopics && ( + + )} +
{activeSection === "knowledge" && ( @@ -496,6 +617,7 @@ export function SidebarContentPanel({ selectedPath={selectedPath} expandedPaths={expandedPaths} onSelectFile={onSelectFile} + onToggleFolder={onToggleFolder} actions={knowledgeActions} onVoiceNoteCreated={onVoiceNoteCreated} /> @@ -515,11 +637,24 @@ export function SidebarContentPanel({ {isRowboatConnected && billing ? (
- - {billing.subscriptionPlan ?? 'Free'} plan - -
@@ -876,6 +1011,7 @@ function KnowledgeSection({ selectedPath, expandedPaths, onSelectFile, + onToggleFolder, actions, onVoiceNoteCreated, }: { @@ -883,6 +1019,7 @@ function KnowledgeSection({ selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void + onToggleFolder?: (path: string) => void actions: KnowledgeActions onVoiceNoteCreated?: (path: string) => void }) { @@ -972,6 +1109,7 @@ function KnowledgeSection({ selectedPath={selectedPath} expandedPaths={expandedPaths} onSelect={onSelectFile} + onToggleFolder={onToggleFolder} actions={actions} /> ))} @@ -994,18 +1132,28 @@ function KnowledgeSection({ ) } +function countFiles(node: TreeNode): number { + if (node.kind === 'file') return 1 + return (node.children ?? []).reduce((sum, child) => sum + countFiles(child), 0) +} + +/** Display name overrides for top-level knowledge folders */ +const FOLDER_DISPLAY_NAMES: Record = {} + // Tree component for file browser function Tree({ item, selectedPath, expandedPaths, onSelect, + onToggleFolder, actions, }: { item: TreeNode selectedPath: string | null expandedPaths: Set onSelect: (path: string, kind: "file" | "dir") => void + onToggleFolder?: (path: string) => void actions: KnowledgeActions }) { const isDir = item.kind === 'dir' @@ -1013,6 +1161,7 @@ function Tree({ const isSelected = selectedPath === item.path const [isRenaming, setIsRenaming] = useState(false) const isSubmittingRef = React.useRef(false) + const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name // For files, strip .md extension for editing const baseName = !isDir && item.name.endsWith('.md') @@ -1141,6 +1290,61 @@ function Tree({ ) } + // Top-level knowledge folders open bases view — render as flat items + const parts = item.path.split('/') + const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' + + if (isBasesFolder) { + return ( + + + + onSelect(item.path, item.kind)}> + +
+ {displayName} + {countFiles(item)} +
+
+ {onToggleFolder && (item.children?.length ?? 0) > 0 && ( + { + e.stopPropagation() + onToggleFolder(item.path) + }} + > + + + )} + {isExpanded && ( + + {(item.children ?? []).map((subItem, index) => ( + + ))} + + )} +
+
+ {contextMenuContent} +
+ ) + } + if (!isDir) { return ( @@ -1183,7 +1387,10 @@ function Tree({ - {item.name} +
+ {displayName} + {countFiles(item)} +
@@ -1195,6 +1402,7 @@ function Tree({ selectedPath={selectedPath} expandedPaths={expandedPaths} onSelect={onSelect} + onToggleFolder={onToggleFolder} actions={actions} /> ))} @@ -1300,9 +1508,6 @@ function TasksSection({ }} >
- {processingRunIds?.has(run.id) ? ( - - ) : null} {run.title || '(Untitled chat)'} {run.createdAt ? ( diff --git a/apps/x/apps/renderer/src/components/suggested-topics-view.tsx b/apps/x/apps/renderer/src/components/suggested-topics-view.tsx new file mode 100644 index 00000000..4440aba9 --- /dev/null +++ b/apps/x/apps/renderer/src/components/suggested-topics-view.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react' +import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react' +import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js' + +const SUGGESTED_TOPICS_PATH = 'suggested-topics.md' +const LEGACY_SUGGESTED_TOPICS_PATHS = [ + 'config/suggested-topics.md', + 'knowledge/Notes/Suggested Topics.md', +] + +/** Parse suggestedtopic code-fence blocks from the markdown file content. */ +function parseTopics(content: string): SuggestedTopicBlock[] { + const topics: SuggestedTopicBlock[] = [] + const regex = /```suggestedtopic\s*\n([\s\S]*?)```/g + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) { + try { + const parsed = JSON.parse(match[1].trim()) + const topic = SuggestedTopicBlockSchema.parse(parsed) + topics.push(topic) + } catch { + // Skip malformed blocks + } + } + + if (topics.length > 0) return topics + + const lines = content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + + for (const line of lines) { + try { + const parsed = JSON.parse(line) + const topic = SuggestedTopicBlockSchema.parse(parsed) + topics.push(topic) + } catch { + // Skip malformed lines + } + } + + return topics +} + +function serializeTopics(topics: SuggestedTopicBlock[]): string { + const blocks = topics.map((topic) => [ + '```suggestedtopic', + JSON.stringify(topic), + '```', + ].join('\n')) + + return ['# Suggested Topics', ...blocks].join('\n\n') + '\n' +} + +const CATEGORY_COLORS: Record = { + Meetings: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + Projects: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', + People: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + Organizations: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', + Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', +} + +function getCategoryColor(category?: string): string { + if (!category) return 'bg-muted text-muted-foreground' + return CATEGORY_COLORS[category] ?? 'bg-muted text-muted-foreground' +} + +interface TopicCardProps { + topic: SuggestedTopicBlock + onTrack: () => void + isRemoving: boolean +} + +function TopicCard({ topic, onTrack, isRemoving }: TopicCardProps) { + return ( +
+
+

+ {topic.title} +

+ {topic.category && ( + + {topic.category} + + )} +
+

+ {topic.description} +

+ +
+ ) +} + +interface SuggestedTopicsViewProps { + onExploreTopic: (topic: SuggestedTopicBlock) => void +} + +export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) { + const [topics, setTopics] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [removingIndex, setRemovingIndex] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + let result + try { + result = await window.ipc.invoke('workspace:readFile', { + path: SUGGESTED_TOPICS_PATH, + }) + } catch { + let legacyResult: { data?: string } | null = null + let legacyPath: string | null = null + for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) { + try { + legacyResult = await window.ipc.invoke('workspace:readFile', { path }) + legacyPath = path + break + } catch { + // Try next legacy location. + } + } + if (!legacyResult || !legacyPath || legacyResult.data === undefined) { + throw new Error('Suggested topics file not found') + } + await window.ipc.invoke('workspace:writeFile', { + path: SUGGESTED_TOPICS_PATH, + data: legacyResult.data, + opts: { encoding: 'utf8' }, + }) + await window.ipc.invoke('workspace:remove', { + path: legacyPath, + opts: { trash: true }, + }) + result = legacyResult + } + if (cancelled) return + if (result.data) { + setTopics(parseTopics(result.data)) + } + } catch { + if (!cancelled) setError('No suggested topics yet. Check back once your knowledge graph has more data.') + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { cancelled = true } + }, []) + + const handleTrack = useCallback( + async (topic: SuggestedTopicBlock, topicIndex: number) => { + if (removingIndex !== null) return + const nextTopics = topics.filter((_, idx) => idx !== topicIndex) + setRemovingIndex(topicIndex) + setError(null) + try { + await window.ipc.invoke('workspace:writeFile', { + path: SUGGESTED_TOPICS_PATH, + data: serializeTopics(nextTopics), + opts: { encoding: 'utf8' }, + }) + setTopics(nextTopics) + } catch (err) { + console.error('Failed to remove suggested topic:', err) + setError('Failed to update suggested topics. Please try again.') + return + } finally { + setRemovingIndex(null) + } + + onExploreTopic(topic) + }, + [onExploreTopic, removingIndex, topics], + ) + + if (loading) { + return ( +
+ +
+ ) + } + + if (error || topics.length === 0) { + return ( +
+
+ +
+

+ {error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'} +

+
+ ) + } + + return ( +
+
+
+ +

Suggested Topics

+
+

+ Suggested notes surfaced from your knowledge graph. Track one to start a tracking note. +

+
+
+
+ {topics.map((topic, i) => ( + { void handleTrack(topic, i) }} + isRemoving={removingIndex === i} + /> + ))} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/tab-bar.tsx b/apps/x/apps/renderer/src/components/tab-bar.tsx index 744f578d..a81c4ece 100644 --- a/apps/x/apps/renderer/src/components/tab-bar.tsx +++ b/apps/x/apps/renderer/src/components/tab-bar.tsx @@ -29,7 +29,6 @@ export function TabBar({ activeTabId, getTabTitle, getTabId, - isProcessing, onSwitchTab, onCloseTab, layout = 'fill', @@ -47,7 +46,6 @@ export function TabBar({ {tabs.map((tab, index) => { const tabId = getTabId(tab) const isActive = tabId === activeTabId - const processing = isProcessing?.(tab) ?? false const title = getTabTitle(tab) return ( @@ -67,9 +65,6 @@ export function TabBar({ )} style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }} > - {processing && ( - - )} {title} {(allowSingleTabClose || tabs.length > 1) && ( = { + '* * * * *': 'Every minute', + '*/5 * * * *': 'Every 5 minutes', + '*/15 * * * *': 'Every 15 minutes', + '*/30 * * * *': 'Every 30 minutes', + '0 * * * *': 'Hourly', + '0 */2 * * *': 'Every 2 hours', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 0 * * *': 'Daily at midnight', + '0 8 * * *': 'Daily at 8 AM', + '0 9 * * *': 'Daily at 9 AM', + '0 12 * * *': 'Daily at noon', + '0 18 * * *': 'Daily at 6 PM', + '0 9 * * 1-5': 'Weekdays at 9 AM', + '0 17 * * 1-5': 'Weekdays at 5 PM', + '0 0 * * 0': 'Sundays at midnight', + '0 0 * * 1': 'Mondays at midnight', + '0 0 1 * *': 'First of each month', +} + +function describeCron(expr: string): string { + return CRON_PHRASES[expr.trim()] ?? expr +} + +type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt' +type ScheduleSummary = { icon: ScheduleIconKind; text: string } + +function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary { + if (!schedule) return { icon: 'bolt', text: 'Manual only' } + if (schedule.type === 'once') { + return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` } + } + if (schedule.type === 'cron') { + return { icon: 'timer', text: describeCron(schedule.expression) } + } + if (schedule.type === 'window') { + return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` } + } + return { icon: 'calendar', text: 'Scheduled' } +} + +function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) { + if (icon === 'timer') return + if (icon === 'calendar' || icon === 'target') return + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +type Tab = 'what' | 'when' | 'event' | 'details' + +export function TrackModal() { + const [open, setOpen] = useState(false) + const [detail, setDetail] = useState(null) + const [yaml, setYaml] = useState('') + const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState('what') + const [editingRaw, setEditingRaw] = useState(false) + const [rawDraft, setRawDraft] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const textareaRef = useRef(null) + + // Listen for the open event and seed modal state. + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent + const d = ev.detail + if (!d?.trackId || !d?.filePath) return + setDetail(d) + setYaml(d.initialYaml ?? '') + setActiveTab('what') + setEditingRaw(false) + setRawDraft('') + setShowAdvanced(false) + setConfirmingDelete(false) + setError(null) + setOpen(true) + void fetchFresh(d) + } + window.addEventListener('rowboat:open-track-modal', handler as EventListener) + return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => { + try { + setLoading(true) + const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) }) + if (res?.success && res.yaml) { + setYaml(res.yaml) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, []) + + const track = useMemo | null>(() => { + if (!yaml) return null + try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null } + }, [yaml]) + + const trackId = track?.trackId ?? detail?.trackId ?? '' + const instruction = track?.instruction ?? '' + const active = track?.active ?? true + const schedule = track?.schedule + const eventMatchCriteria = track?.eventMatchCriteria ?? '' + const lastRunAt = track?.lastRunAt ?? '' + const lastRunId = track?.lastRunId ?? '' + const lastRunSummary = track?.lastRunSummary ?? '' + const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule]) + const triggerType: 'scheduled' | 'event' | 'manual' = + schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual' + + const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : '' + + const allTrackStatus = useTrackStatus() + const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const } + const isRunning = runState.status === 'running' + + useEffect(() => { + if (editingRaw && textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length, + ) + } + }, [editingRaw]) + + const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [ + { key: 'what', label: 'What to track', visible: true }, + { key: 'when', label: 'When to run', visible: !!schedule }, + { key: 'event', label: 'Event matching', visible: !!eventMatchCriteria }, + { key: 'details', label: 'Details', visible: true }, + ] + const shown = visibleTabs.filter(t => t.visible) + + useEffect(() => { + if (!shown.some(t => t.key === activeTab)) setActiveTab('what') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schedule, eventMatchCriteria]) + + // ------------------------------------------------------------------------- + // IPC-backed mutations + // ------------------------------------------------------------------------- + + const runUpdate = useCallback(async (updates: Record) => { + if (!detail) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:update', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + updates, + }) + if (res?.success && res.yaml) { + setYaml(res.yaml) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [detail]) + + const handleToggleActive = useCallback(() => { + void runUpdate({ active: !active }) + }, [active, runUpdate]) + + const handleRun = useCallback(async () => { + if (!detail || isRunning) return + try { + await window.ipc.invoke('track:run', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [detail, isRunning]) + + const handleSaveRaw = useCallback(async () => { + if (!detail) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:replaceYaml', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + yaml: rawDraft, + }) + if (res?.success && res.yaml) { + setYaml(res.yaml) + setEditingRaw(false) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [detail, rawDraft]) + + const handleDelete = useCallback(async () => { + if (!detail) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:delete', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + }) + if (res?.success) { + // Tell the editor to remove the node so Tiptap's next save doesn't + // re-create the track block on disk. + try { detail.onDeleted() } catch { /* editor may have unmounted */ } + setOpen(false) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [detail]) + + const handleEditWithCopilot = useCallback(() => { + if (!detail) return + window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', { + detail: { + trackId: detail.trackId, + filePath: detail.filePath, + }, + })) + setOpen(false) + }, [detail]) + + if (!detail) return null + + return ( + + +
+
+
+ +
+
+ + + {trackId || 'Track'} + + + + {scheduleSummary.text} + {eventMatchCriteria && triggerType === 'scheduled' && ( + · also event-driven + )} + + +
+
+
+ +
+
+ + {/* Tabs */} +
+ {shown.map(tab => ( + + ))} +
+ + {/* Body */} +
+ {loading &&
Loading latest…
} + + {activeTab === 'what' && ( +
+ {instruction + ? {instruction} + : No instruction set.} +
+ )} + + {activeTab === 'when' && schedule && ( +
+
+ + {scheduleSummary.text} +
+
+
Type
{schedule.type}
+ {schedule.type === 'cron' && ( + <> +
Expression
{schedule.expression}
+ + )} + {schedule.type === 'window' && ( + <> +
Expression
{schedule.cron}
+
Window
{schedule.startTime} – {schedule.endTime}
+ + )} + {schedule.type === 'once' && ( + <> +
Runs at
{formatDateTime(schedule.runAt)}
+ + )} +
+
+ )} + + {activeTab === 'event' && ( +
+ {eventMatchCriteria + ? {eventMatchCriteria} + : No event matching set.} +
+ )} + + {activeTab === 'details' && ( +
+
+
Track ID
{trackId}
+
File
{detail.filePath}
+
Status
{active ? 'Active' : 'Paused'}
+ {lastRunAt && (<> +
Last run
{formatDateTime(lastRunAt)}
+ )} + {lastRunId && (<> +
Run ID
{lastRunId}
+ )} + {lastRunSummary && (<> +
Summary
{lastRunSummary}
+ )} +
+
+ )} + + {/* Advanced (raw YAML) — all tabs */} +
+ + {showAdvanced && ( +
+