mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
Merge branch 'dev' into feat/skill-system
Bring the new skill system branch up to date with dev. Conflicts resolved in favor of the new skill-system architecture: built-in skill .ts files (including dev-added tracks, browser-control, composio-integration) are deleted in favor of SKILL.md content sourced from outside the source tree. buildCopilotInstructions now sources the catalog from SkillResolver and filters composio-integration when Composio is not configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
66c0bc5fa7
171 changed files with 17719 additions and 2984 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@
|
|||
.vscode/
|
||||
data/
|
||||
.venv/
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
README.md
52
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
|
||||
[](https://www.youtube.com/watch?v=7xTpciZCfpw)
|
||||
|
||||
|
||||
[](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": "<key>"
|
||||
}
|
||||
```
|
||||
### 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
|
||||
|
||||
|
|
|
|||
BIN
apps/docs/docs/img/google-setup/07-enter-credentials.png
Normal file
BIN
apps/docs/docs/img/google-setup/07-enter-credentials.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
343
apps/x/TRACKS.md
Normal file
343
apps/x/TRACKS.md
Normal file
|
|
@ -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 * * * *"
|
||||
```
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
2:30 PM, Central Time
|
||||
<!--/track-target:chicago-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 `<!--track-target:ID-->` 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: <thread markdown> })`.
|
||||
- **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/<id>.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/<id>.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<string>` 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 `<!--track-target:ID-->` 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.
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.screen-capture</key>
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
): Promise<AuthServerResult> {
|
||||
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(`
|
||||
|
|
|
|||
243
apps/x/apps/main/src/browser/control-service.ts
Normal file
243
apps/x/apps/main/src/browser/control-service.ts
Normal file
|
|
@ -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<BrowserControlResult> {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
apps/x/apps/main/src/browser/ipc.ts
Normal file
81
apps/x/apps/main/src/browser/ipc.ts
Normal file
|
|
@ -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<K extends keyof IPCChannels> = (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
args: IPCChannels[K]['req'],
|
||||
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
41
apps/x/apps/main/src/browser/navigation.ts
Normal file
41
apps/x/apps/main/src/browser/navigation.ts
Normal file
|
|
@ -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)}`;
|
||||
}
|
||||
546
apps/x/apps/main/src/browser/page-scripts.ts
Normal file
546
apps/x/apps/main/src/browser/page-scripts.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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);
|
||||
}
|
||||
797
apps/x/apps/main/src/browser/view.ts
Normal file
797
apps/x/apps/main/src/browser/view.ts
Normal file
|
|
@ -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<void> {
|
||||
if (ms <= 0) return;
|
||||
abortIfNeeded(signal);
|
||||
await new Promise<void>((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<string, BrowserTab>();
|
||||
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<string, CachedSnapshot>();
|
||||
|
||||
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<void> {
|
||||
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<T>(
|
||||
script: string,
|
||||
signal?: AbortSignal,
|
||||
options?: { waitForReady?: boolean },
|
||||
): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<RawBrowserPageSnapshot>(
|
||||
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<BrowserPageSnapshot | null> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
@ -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<string, {
|
||||
toolkitSlug: string;
|
||||
connectedAccountId: string;
|
||||
authConfigId: string;
|
||||
server: import('http').Server;
|
||||
timeout: NodeJS.Timeout;
|
||||
}>();
|
||||
|
||||
/**
|
||||
|
|
@ -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<string, unknown>
|
||||
): Promise<z.infer<typeof ZExecuteActionResponse>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</style></head><body>${html}</body></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<typeof workspaceShared.WorkspaceCh
|
|||
|
||||
/**
|
||||
* Start workspace watcher
|
||||
* Watches ~/.rowboat recursively and emits change events to renderer
|
||||
* Watches the configured workspace root recursively and emits change events to renderer
|
||||
*
|
||||
* This should be called once when the app starts (from main.ts).
|
||||
* The watcher runs as a main-process service and catches ALL filesystem changes
|
||||
|
|
@ -350,6 +373,19 @@ export async function startServicesWatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
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<IGranolaConfigRepo>('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<string, unknown>);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, {
|
||||
codeVerifier: string;
|
||||
|
|
@ -74,19 +112,19 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
/**
|
||||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
|
||||
const config = getProviderConfig(provider);
|
||||
const resolveClientId = async (): Promise<string> => {
|
||||
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
|||
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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
getZoomFactor: () => webFrame.getZoomFactor(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string | null>(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 (
|
||||
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
|
||||
{/* Toolkit initial */}
|
||||
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name & status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{connectionState === "connected" && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{connectionState === "error" && errorMessage && (
|
||||
<p className="text-xs text-destructive truncate">{errorMessage}</p>
|
||||
)}
|
||||
{connectionState === "idle" && isToolRunning && (
|
||||
<p className="text-xs text-muted-foreground">Waiting to connect...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
{connectionState === "connected" ? (
|
||||
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
|
||||
) : connectionState === "connecting" ? (
|
||||
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
|
||||
<LoaderIcon className="size-3 animate-spin mr-1" />
|
||||
Connecting...
|
||||
</Button>
|
||||
) : connectionState === "error" ? (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<XCircleIcon className="size-3.5 text-destructive" />
|
||||
<Button size="sm" variant="outline" onClick={handleConnect} className="text-xs h-7">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : isToolRunning ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<Button size="sm" onClick={handleConnect} className="text-xs h-7 flex-shrink-0">
|
||||
<Link2Icon className="size-3 mr-1" />
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>;
|
||||
isAtBottom: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
const ScrollPreservationContext = createContext<ScrollPreservationContextValue | null>(null);
|
||||
const ConversationContext = createContext<ConversationContextValue | null>(null);
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
|
||||
export type ConversationProps = ComponentProps<"div"> & {
|
||||
anchorMessageId?: string | null;
|
||||
anchorRequestKey?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
|
||||
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
|
||||
const isUserEngagedRef = useRef(false);
|
||||
const savedScrollTopRef = useRef<number>(0);
|
||||
const lastScrollHeightRef = useRef<number>(0);
|
||||
export const Conversation = ({
|
||||
anchorMessageId = null,
|
||||
anchorRequestKey,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ConversationProps) => {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const spacerRef = useRef<HTMLDivElement | null>(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<HTMLElement>(
|
||||
`[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<ConversationContextValue>(
|
||||
() => ({
|
||||
contentRef,
|
||||
isAtBottom,
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
}),
|
||||
[isAtBottom, scrollToBottom]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollPreservationContext.Provider value={contextValue}>
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
<ConversationContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("relative flex-1 overflow-hidden", className)}
|
||||
role="log"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom>
|
||||
</ScrollPreservationContext.Provider>
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto [scrollbar-gutter:stable]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{children}
|
||||
<div ref={spacerRef} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}: ConversationContentProps) => {
|
||||
const { contentRef } = useConversationContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
ref={contentRef}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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) => (
|
||||
<div
|
||||
|
|
@ -183,13 +274,15 @@ export const ConversationEmptyState = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
export const ScrollPositionPreserver = () => null;
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
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 && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
"absolute bottom-6 left-[50%] z-10 h-12 w-12 translate-x-[-50%] rounded-full border border-border/70 bg-background/95 text-foreground shadow-lg backdrop-blur-sm transition hover:bg-background",
|
||||
className
|
||||
)}
|
||||
aria-label="Scroll to latest message"
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
<ArrowDownIcon className="size-6" strokeWidth={1.75} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 <FilePathCard filePath={text} />
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof childProps.className === 'string' &&
|
||||
childProps.className.includes('language-mermaid')
|
||||
) {
|
||||
const text = typeof childProps.children === 'string'
|
||||
? childProps.children.trim()
|
||||
: ''
|
||||
if (text) {
|
||||
return <MermaidRenderer source={text} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Passthrough for all other code blocks - return children directly
|
||||
|
|
|
|||
|
|
@ -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<FileMention[]>([]);
|
||||
|
||||
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 }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
}) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-xs font-mono",
|
||||
"whitespace-pre-wrap text-xs font-mono break-all",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -98,24 +98,33 @@ export const ToolHeader = ({
|
|||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}: ToolHeaderProps) => {
|
||||
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
};
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
|
|
@ -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) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
|
||||
<ToolCode code={formatToolValue(input ?? {})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = <ToolCode code={formatToolValue(output ?? null)} />;
|
||||
} else if (typeof output === "string") {
|
||||
Output = <ToolCode code={formatToolValue(output)} />;
|
||||
let OutputNode: ReactNode = null;
|
||||
if (errorText) {
|
||||
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
|
||||
} else if (output != null) {
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
OutputNode = <ToolCode code={formatToolValue(output)} />;
|
||||
} else if (typeof output === "string") {
|
||||
OutputNode = <ToolCode code={output} />;
|
||||
} else {
|
||||
OutputNode = <div>{output as ReactNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && (
|
||||
<div className="mb-2 font-sans text-xs text-destructive">
|
||||
{errorText}
|
||||
<div className="border-t">
|
||||
{/* Tabs */}
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
|
||||
activeTab === "parameters"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("parameters")}
|
||||
>
|
||||
Parameters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
|
||||
activeTab === "result"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("result")}
|
||||
>
|
||||
Result
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-3">
|
||||
{activeTab === "parameters" && (
|
||||
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
|
||||
<ToolCode code={formatToolValue(input ?? {})} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "result" && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border p-3 max-h-64 overflow-auto",
|
||||
errorText ? "bg-destructive/10" : "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{hasOutput ? (
|
||||
<div className={cn(errorText && "text-destructive")}>
|
||||
{OutputNode}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">(pending...)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
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<NoteEntry[]>(() => {
|
||||
return collectFiles(tree).map((f) => ({
|
||||
|
|
@ -652,22 +670,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
|
|||
</thead>
|
||||
<tbody>
|
||||
{pageNotes.map((note) => (
|
||||
<tr
|
||||
<NoteRow
|
||||
key={note.path}
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||
<CellRenderer
|
||||
note={note}
|
||||
column={col}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
note={note}
|
||||
visibleColumns={visibleColumns}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
onSelectNote={onSelectNote}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
{pageNotes.length === 0 && (
|
||||
<tr>
|
||||
|
|
@ -770,6 +781,17 @@ function CellRenderer({
|
|||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
|
||||
}
|
||||
|
||||
// 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 <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(ms)}</span>
|
||||
}
|
||||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{value}</span>
|
||||
}
|
||||
|
||||
// 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<HTMLInputElement>(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 = (
|
||||
<tr
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||
{col === 'name' && isRenaming ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<CellRenderer
|
||||
note={note}
|
||||
column={col}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
|
||||
if (!actions) return row
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
{row}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={handleCopyPath}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBadge({
|
||||
category,
|
||||
value,
|
||||
|
|
|
|||
418
apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx
Normal file
418
apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx
Normal file
|
|
@ -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<HTMLElement>('[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<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
|
||||
const activeTabIdRef = useRef<string | null>(null)
|
||||
const addressFocusedRef = useRef(false)
|
||||
const viewportRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[data-slot="sidebar-inset"]')
|
||||
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[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 (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="flex h-9 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||
<TabBar
|
||||
tabs={state.tabs}
|
||||
activeTabId={state.activeTabId ?? ''}
|
||||
getTabTitle={getBrowserTabTitle}
|
||||
getTabId={(tab) => tab.id}
|
||||
onSwitchTab={handleSwitchTab}
|
||||
onCloseTab={handleCloseTab}
|
||||
layout="scroll"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewTab}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="New browser tab"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
|
||||
style={{ minHeight: CHROME_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={!activeTab?.canGoBack}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||
activeTab?.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForward}
|
||||
disabled={!activeTab?.canGoForward}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||
activeTab?.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
)}
|
||||
aria-label="Forward"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReload}
|
||||
disabled={!activeTab}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||
activeTab ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
)}
|
||||
aria-label="Reload"
|
||||
>
|
||||
{activeTab?.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<form onSubmit={handleSubmitAddress} className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={addressValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close browser"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="relative min-h-0 min-w-0 flex-1"
|
||||
data-browser-viewport
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || 'Model'}
|
||||
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!appUrl) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
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 (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
|
|
@ -296,7 +358,7 @@ export function ChatSidebar({
|
|||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
|
|
@ -316,7 +378,7 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
|
|
@ -337,6 +399,21 @@ export function ChatSidebar({
|
|||
/>
|
||||
)
|
||||
}
|
||||
const composioConnectData = getComposioConnectCardData(item)
|
||||
if (composioConnectData) {
|
||||
if (composioConnectData.hidden) return null
|
||||
return (
|
||||
<ComposioConnectCard
|
||||
key={item.id}
|
||||
toolkitSlug={composioConnectData.toolkitSlug}
|
||||
toolkitDisplayName={composioConnectData.toolkitDisplayName}
|
||||
status={item.status}
|
||||
alreadyConnected={composioConnectData.alreadyConnected}
|
||||
onConnected={onComposioConnected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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)}
|
||||
>
|
||||
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
|
||||
if (isErrorMessage(item)) {
|
||||
const billingError = matchBillingError(item.message)
|
||||
if (billingError) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
|
||||
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
|
||||
<BillingErrorCTA label={billingError.cta} />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from="assistant">
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
|
||||
</MessageContent>
|
||||
|
|
@ -383,6 +473,7 @@ export function ChatSidebar({
|
|||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
data-chat-sidebar-root
|
||||
onMouseDownCapture={onActivate}
|
||||
onFocusCapture={onActivate}
|
||||
className={cn(
|
||||
|
|
@ -466,9 +557,12 @@ export function ChatSidebar({
|
|||
)}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
>
|
||||
<Conversation
|
||||
anchorMessageId={viewportAnchors[tab.id]?.messageId}
|
||||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
|
|
@ -526,10 +620,11 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
|
||||
</div>
|
||||
|
||||
{/* Granola - show in unconnected mode only if not enabled */}
|
||||
{(!isUnconnectedMode || isGranolaUnconnected) && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mic className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Granola</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Local meeting notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{c.granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
<Switch
|
||||
checked={c.granolaEnabled}
|
||||
onCheckedChange={c.handleGranolaToggle}
|
||||
disabled={c.granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fireflies */}
|
||||
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Team Communication Section */}
|
||||
{hasUnconnectedSlack && (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{c.slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(c.slackLoading || c.slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{c.slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => c.handleSlackDisable()}
|
||||
disabled={c.slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleSlackEnable}
|
||||
disabled={c.slackLoading || c.slackDiscovering}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.slackPickerOpen && (
|
||||
<div className="mt-2 ml-11 space-y-2">
|
||||
{c.slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{c.slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
c.setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackSaveWorkspaces}
|
||||
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
|
||||
<div className="p-6 pb-0">
|
||||
<DialogHeader className="space-y-1.5">
|
||||
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
|
||||
<DialogTitle className="text-lg font-semibold">Google OAuth Credentials</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
{description ?? "Enter the client ID for your Google OAuth app to connect."}
|
||||
{description ?? "Enter the credentials for your Google OAuth app to connect."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
|
@ -76,6 +79,25 @@ export function GoogleClientIdModal({
|
|||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-secret">
|
||||
Client Secret
|
||||
</label>
|
||||
<Input
|
||||
id="google-client-secret"
|
||||
type="password"
|
||||
placeholder="GOCSPX-..."
|
||||
value={clientSecret}
|
||||
onChange={(event) => setClientSecret(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Need help?{" "}
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2, Search, X } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export type GraphNode = {
|
||||
|
|
@ -48,7 +48,7 @@ const FLOAT_VARIANCE = 2
|
|||
const FLOAT_SPEED_BASE = 0.0006
|
||||
const FLOAT_SPEED_VARIANCE = 0.00025
|
||||
|
||||
export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: GraphViewProps) {
|
||||
export function GraphView({ nodes, edges, error, onSelectNode }: GraphViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const positionsRef = useRef<Map<string, NodePosition>>(new Map())
|
||||
const motionSeedsRef = useRef<Map<string, { phase: number; amplitude: number; speed: number }>>(new Map())
|
||||
|
|
@ -456,22 +456,13 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="graph-view relative h-full w-full">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Building graph…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
{error ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && nodes.length === 0 ? (
|
||||
{!error && nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No notes found.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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*<!--track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
.replace(
|
||||
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\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<string, unknown> }>
|
||||
attrs?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown> }>
|
||||
attrs?: Record<string, unknown>
|
||||
}>
|
||||
attrs?: Record<string, unknown>
|
||||
}): 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<unknown>; attrs?: Record<string, unknown> }>
|
||||
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<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, 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<unknown>; attrs?: Record<string, unknown> }>
|
||||
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<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, 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 `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'trackTargetClose':
|
||||
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
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(``)
|
||||
return ``
|
||||
}
|
||||
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<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
onPrimaryHeadingCommit,
|
||||
|
|
@ -443,7 +579,7 @@ export function MarkdownEditor({
|
|||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}: MarkdownEditorProps) {
|
||||
}, ref) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(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({
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
89
apps/x/apps/renderer/src/components/mermaid-renderer.tsx
Normal file
89
apps/x/apps/renderer/src/components/mermaid-renderer.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null)
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={className}>
|
||||
<div style={{ color: 'var(--destructive, #ef4444)', fontSize: 12, marginBottom: 4 }}>
|
||||
Invalid mermaid syntax
|
||||
</div>
|
||||
<pre style={{ fontSize: 12, opacity: 0.7, whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
<code>{source}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!svg) {
|
||||
return (
|
||||
<div className={className} style={{ fontSize: 13, opacity: 0.5 }}>
|
||||
Rendering diagram...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
style={{ lineHeight: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
|
|
@ -109,28 +109,6 @@ export function CompletionStep({ state }: CompletionStepProps) {
|
|||
<span>Fireflies (Meeting transcripts)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{granolaEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Granola (Local meeting notes)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{slackEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.65 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Slack (Team communication)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center leading-relaxed mb-8">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{providersLoading ? (
|
||||
|
|
@ -122,7 +116,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
|
|||
{useComposioForGoogle ? (
|
||||
<ProviderCard
|
||||
name="Gmail"
|
||||
description="Sync your email for context-aware assistance"
|
||||
description="Read emails for context and drafts."
|
||||
icon={<GmailIcon />}
|
||||
iconBg="bg-red-500/10"
|
||||
iconColor="text-red-500"
|
||||
|
|
@ -145,7 +139,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
|
|||
{useComposioForGoogleCalendar && (
|
||||
<ProviderCard
|
||||
name="Google Calendar"
|
||||
description="Sync calendar events for scheduling awareness"
|
||||
description="Read meetings and your schedule."
|
||||
icon={<Calendar className="size-5" />}
|
||||
iconBg="bg-blue-500/10"
|
||||
iconColor="text-blue-500"
|
||||
|
|
@ -162,29 +156,31 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
|
|||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
<ProviderCard
|
||||
name="Granola"
|
||||
description="Sync your local meeting notes for richer context"
|
||||
icon={<GranolaIcon />}
|
||||
iconBg="bg-purple-500/10"
|
||||
iconColor="text-purple-500"
|
||||
providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
{granolaLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={handleGranolaToggle}
|
||||
disabled={granolaLoading}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: cardIndex++ * 0.06 }}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-10 rounded-lg flex items-center justify-center shrink-0 bg-green-500/10">
|
||||
<span className="text-green-500"><FileText className="size-5" /></span>
|
||||
</div>
|
||||
}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">Rowboat Meeting Notes</div>
|
||||
<div className="text-xs text-muted-foreground truncate">Built in. Ready to use.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{providers.includes('fireflies-ai') && (
|
||||
<ProviderCard
|
||||
name="Fireflies"
|
||||
description="Import AI-powered meeting transcripts automatically"
|
||||
description="Import existing notes."
|
||||
icon={<FirefliesIcon />}
|
||||
iconBg="bg-amber-500/10"
|
||||
iconColor="text-amber-500"
|
||||
|
|
@ -194,83 +190,6 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Communication */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
<div>
|
||||
<ProviderCard
|
||||
name="Slack"
|
||||
description={
|
||||
slackEnabled && slackWorkspaces.length > 0
|
||||
? slackWorkspaces.map(w => w.name).join(', ')
|
||||
: "Enable Rowboat to understand your team conversations and provide relevant context"
|
||||
}
|
||||
icon={<SlackIcon />}
|
||||
iconBg="bg-emerald-500/10"
|
||||
iconColor="text-emerald-500"
|
||||
providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
{(slackLoading || slackDiscovering) && <Loader2 className="size-3 animate-spin" />}
|
||||
{slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => handleSlackDisable()}
|
||||
disabled={slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackEnable}
|
||||
disabled={slackLoading || slackDiscovering}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-[3.25rem] space-y-2 pl-4 border-l-2 border-muted">
|
||||
{slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackSaveWorkspaces}
|
||||
disabled={slackSelectedUrls.size === 0 || slackLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to all models — no API keys needed.
|
||||
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { setGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
|
|
@ -576,14 +576,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
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}`)
|
||||
|
|
@ -605,22 +605,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
// 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])
|
||||
|
||||
// Switch to rowboat path from BYOK inline callout
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
|
|
@ -20,21 +22,50 @@ interface SearchResult {
|
|||
}
|
||||
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
type Mode = 'chat' | 'search'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
return ['chat'] // "tasks" tab maps to chat
|
||||
}
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
export type CommandPaletteContext = {
|
||||
path: string
|
||||
lineNumber: number
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||
export type CommandPaletteMention = {
|
||||
path: string
|
||||
displayName: string
|
||||
lineNumber?: number
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
// Search mode
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
// Chat mode
|
||||
initialContext?: CommandPaletteContext | null
|
||||
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelectFile,
|
||||
onSelectRun,
|
||||
initialContext,
|
||||
onChatSubmit,
|
||||
}: CommandPaletteProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [mode, setMode] = useState<Mode>('chat')
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
|
||||
const chatInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
|
@ -43,17 +74,45 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
// Sync filter preselection when dialog opens
|
||||
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
|
||||
// and reset search filters.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('chat')
|
||||
setChatInput('')
|
||||
setContextChip(initialContext ?? null)
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection])
|
||||
}, [open, activeSection, initialContext])
|
||||
|
||||
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
|
||||
// swallow it. Only fires while the dialog is open.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
|
||||
}
|
||||
document.addEventListener('keydown', handler, true)
|
||||
return () => document.removeEventListener('keydown', handler, true)
|
||||
}, [open])
|
||||
|
||||
// Refocus the appropriate input on mode change so the user can start typing immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const target = mode === 'chat' ? chatInputRef : searchInputRef
|
||||
target.current?.focus()
|
||||
}, [open, mode])
|
||||
|
||||
const toggleType = useCallback((type: SearchType) => {
|
||||
setActiveTypes(new Set([type]))
|
||||
}, [])
|
||||
|
||||
// Search query effect (only meaningful while in search mode, but the debounce keeps running
|
||||
// harmlessly otherwise — empty query skips the IPC call below).
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([])
|
||||
|
|
@ -68,6 +127,8 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setResults(res.results)
|
||||
analytics.searchExecuted(types)
|
||||
posthog.people.set_once({ has_used_search: true })
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -85,11 +146,12 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
return () => { cancelled = true }
|
||||
}, [debouncedQuery, activeTypes])
|
||||
|
||||
// Reset state when dialog closes
|
||||
// Reset transient state on close.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setChatInput('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
|
@ -102,6 +164,20 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
}
|
||||
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||
|
||||
const submitChat = useCallback(() => {
|
||||
const text = chatInput.trim()
|
||||
if (!text && !contextChip) return
|
||||
const mention: CommandPaletteMention | null = contextChip
|
||||
? {
|
||||
path: contextChip.path,
|
||||
displayName: deriveDisplayName(contextChip.path),
|
||||
lineNumber: contextChip.lineNumber,
|
||||
}
|
||||
: null
|
||||
onChatSubmit(text, mention)
|
||||
onOpenChange(false)
|
||||
}, [chatInput, contextChip, onChatSubmit, onOpenChange])
|
||||
|
||||
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||
const chatResults = results.filter(r => r.type === 'chat')
|
||||
|
||||
|
|
@ -109,76 +185,178 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Search"
|
||||
description="Search across knowledge and chats"
|
||||
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
|
||||
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
|
||||
showCloseButton={false}
|
||||
className="top-[20%] translate-y-0"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
{/* Mode strip */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
<ModeButton
|
||||
active={mode === 'chat'}
|
||||
onClick={() => setMode('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
label="Chat"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === 'search'}
|
||||
onClick={() => setMode('search')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Search"
|
||||
/>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
{mode === 'chat' ? (
|
||||
<div className="flex flex-col">
|
||||
<input
|
||||
ref={chatInputRef}
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// cmdk's Command component intercepts Enter for item selection — stop it
|
||||
// before bubbling so we control the chat submit ourselves.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
submitChat()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask copilot anything…"
|
||||
autoFocus
|
||||
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{contextChip && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
|
||||
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextChip(null)}
|
||||
aria-label="Remove context"
|
||||
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
{!contextChip && (
|
||||
<div className="flex items-center px-3 pb-3">
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
|
|
|
|||
|
|
@ -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<ToolkitInfo[]>([])
|
||||
const [toolkitsLoading, setToolkitsLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Connection state
|
||||
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
|
||||
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
{/* Section A: API Key (only in BYOK mode) */}
|
||||
{!rowboatConnected && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Composio API Key</span>
|
||||
{apiKeyConfigured && !showApiKeyInput ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
API key configured
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your Composio API key to browse and enable tool integrations.
|
||||
Get your key from{" "}
|
||||
<a
|
||||
href="https://app.composio.dev/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
app.composio.dev/settings
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder="Paste your Composio API key"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveApiKey}
|
||||
disabled={!apiKeyInput.trim() || apiKeySaving}
|
||||
size="sm"
|
||||
>
|
||||
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
{apiKeyConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowApiKeyInput(false); setApiKeyInput("") }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section B: Toolkit Browser (only when API key configured) */}
|
||||
{apiKeyConfigured && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Toolkits</span>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search toolkits..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolkitsLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading toolkits...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[400px] overflow-y-auto pr-1">
|
||||
{filteredToolkits.map((toolkit) => {
|
||||
const isConnected = connectedToolkits.has(toolkit.slug)
|
||||
const isConnecting = connectingToolkit === toolkit.slug
|
||||
|
||||
return (
|
||||
<div key={toolkit.slug} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Logo */}
|
||||
{toolkit.meta.logo ? (
|
||||
<img
|
||||
src={toolkit.meta.logo}
|
||||
alt=""
|
||||
className="size-7 rounded object-contain shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded bg-muted flex items-center justify-center shrink-0">
|
||||
<Wrench className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name & description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{toolkit.name}</span>
|
||||
{isConnected && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{toolkit.meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connect / Disconnect button */}
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(toolkit.slug)}
|
||||
className="text-xs h-7 shrink-0"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConnect(toolkit.slug)}
|
||||
disabled={isConnecting}
|
||||
className="text-xs h-7 shrink-0"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
|
||||
) : (
|
||||
<><Link2 className="size-3 mr-1" />Connect</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredToolkits.length === 0 && !toolkitsLoading && (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
{searchQuery ? "No toolkits match your search" : "No toolkits available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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<string, string> = {
|
||||
|
|
@ -859,73 +1184,12 @@ const TAG_TYPE_LABELS: Record<string, string> = {
|
|||
"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 }) {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
|
@ -1248,7 +1504,7 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
|
||||
export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>(initialTab ?? "models")
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>(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) {
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "account" || activeTab === "connected-accounts" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : activeTab === "skills" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : (activeTab === "note-tagging" || activeTab === "skills") ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connected-accounts" ? (
|
||||
|
|
@ -1435,6 +1689,8 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
|||
<AppearanceSettings />
|
||||
) : activeTab === "skills" ? (
|
||||
<SkillsSettings dialogOpen={open} onExpandRequest={setSkillsExpanded} />
|
||||
) : activeTab === "tools" ? (
|
||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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) {
|
|||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">{billing.subscriptionPlan ?? 'Free'} Plan</p>
|
||||
{billing.subscriptionStatus && (
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
|
||||
</p>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Trial · {days === 0 ? 'expires today' : days === 1 ? '1 day left' : `${days} days left`}
|
||||
</p>
|
||||
)
|
||||
})() : billing.subscriptionStatus ? (
|
||||
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
|
||||
) : null}
|
||||
{!billing.subscriptionPlan && (
|
||||
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Upgrade
|
||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,6 +191,32 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Payment Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="size-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Payment</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage invoices, payment methods, and billing details.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!billing?.subscriptionPlan}
|
||||
onClick={() => appUrl && window.open(appUrl)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Manage in Stripe
|
||||
</Button>
|
||||
{!billing?.subscriptionPlan && (
|
||||
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Log Out Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Granola */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Mic className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Granola</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Local meeting notes
|
||||
{c.providers.includes('fireflies-ai') && (
|
||||
<>
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{c.granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
<Switch
|
||||
checked={c.granolaEnabled}
|
||||
onCheckedChange={c.handleGranolaToggle}
|
||||
disabled={c.granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fireflies */}
|
||||
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-emerald-600 truncate">
|
||||
{c.slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(c.slackLoading || c.slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{c.slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => c.handleSlackDisable()}
|
||||
disabled={c.slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleSlackEnable}
|
||||
disabled={c.slackLoading || c.slackDiscovering}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.slackPickerOpen && (
|
||||
<div className="mt-2 ml-12 space-y-2">
|
||||
{c.slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{c.slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
c.setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackSaveWorkspaces}
|
||||
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Fireflies */}
|
||||
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
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<typeof Sidebar>
|
||||
|
||||
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<Map<string, string>>(new Map())
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
|
||||
|
|
@ -300,7 +319,7 @@ function SyncStatusBar() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{!isMobile && isCollapsed && isSyncing && (
|
||||
{isCollapsed && isSyncing && (
|
||||
<div
|
||||
className="fixed bottom-4 z-40 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background shadow-sm"
|
||||
style={{ left: "0.5rem" }}
|
||||
|
|
@ -386,6 +405,7 @@ export function SidebarContentPanel({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
onToggleFolder,
|
||||
knowledgeActions,
|
||||
onVoiceNoteCreated,
|
||||
runs = [],
|
||||
|
|
@ -394,6 +414,16 @@ export function SidebarContentPanel({
|
|||
tasksActions,
|
||||
backgroundTasks = [],
|
||||
selectedBackgroundTask,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState = 'idle',
|
||||
meetingSummarizing = false,
|
||||
meetingAvailable = false,
|
||||
onToggleMeeting,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
isSuggestedTopicsOpen = false,
|
||||
onOpenSuggestedTopics,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -404,6 +434,7 @@ export function SidebarContentPanel({
|
|||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(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({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick action buttons */}
|
||||
<div className="titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
||||
{onNewChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
<span>New chat</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
)}
|
||||
{meetingAvailable && onToggleMeeting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMeeting}
|
||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
|
||||
meetingState === 'recording'
|
||||
? "text-red-500 hover:bg-sidebar-accent"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : meetingState === 'recording' ? (
|
||||
<Square className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Radio className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{meetingSummarizing
|
||||
? 'Generating notes…'
|
||||
: meetingState === 'connecting'
|
||||
? 'Starting…'
|
||||
: meetingState === 'recording'
|
||||
? 'Stop recording'
|
||||
: 'Take meeting notes'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onToggleBrowser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBrowserOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
<span>Run browser task</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenSuggestedTopics && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSuggestedTopics}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSuggestedTopicsOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Lightbulb className="size-4" />
|
||||
<span>Suggested Topics</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{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 ? (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{billing.subscriptionPlan ?? 'Free'} plan
|
||||
</span>
|
||||
<button className="rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20">
|
||||
Upgrade
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{billing.subscriptionPlan ? `${billing.subscriptionPlan} plan` : 'No plan'}
|
||||
</span>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
return (
|
||||
<p className="text-[10px] text-sidebar-foreground/60">
|
||||
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
|
||||
>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -876,6 +1011,7 @@ function KnowledgeSection({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
onVoiceNoteCreated,
|
||||
}: {
|
||||
|
|
@ -883,6 +1019,7 @@ function KnowledgeSection({
|
|||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
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<string, string> = {}
|
||||
|
||||
// Tree component for file browser
|
||||
function Tree({
|
||||
item,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
}: {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
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 (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem className="group/file-item">
|
||||
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
{onToggleFolder && (item.children?.length ?? 0) > 0 && (
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
aria-label={isExpanded ? "Collapse folder" : "Expand folder"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFolder(item.path)
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<SidebarMenuSub>
|
||||
{(item.children ?? []).map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDir) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
|
|
@ -1183,7 +1387,10 @@ function Tree({
|
|||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<span>{item.name}</span>
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -1195,6 +1402,7 @@ function Tree({
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -1300,9 +1508,6 @@ function TasksSection({
|
|||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 min-w-0">
|
||||
{processingRunIds?.has(run.id) ? (
|
||||
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
||||
{run.createdAt ? (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
|
|
|
|||
246
apps/x/apps/renderer/src/components/suggested-topics-view.tsx
Normal file
246
apps/x/apps/renderer/src/components/suggested-topics-view.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="group flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-5 transition-all hover:border-border hover:shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold leading-snug text-foreground">
|
||||
{topic.title}
|
||||
</h3>
|
||||
{topic.category && (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${getCategoryColor(topic.category)}`}
|
||||
>
|
||||
{topic.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{topic.description}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTrack}
|
||||
disabled={isRemoving}
|
||||
className="mt-auto flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isRemoving ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Tracking…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Track
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SuggestedTopicsViewProps {
|
||||
onExploreTopic: (topic: SuggestedTopicBlock) => void
|
||||
}
|
||||
|
||||
export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) {
|
||||
const [topics, setTopics] = useState<SuggestedTopicBlock[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [removingIndex, setRemovingIndex] = useState<number | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || topics.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Lightbulb className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Suggested Topics</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Suggested notes surfaced from your knowledge graph. Track one to start a tracking note.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{topics.map((topic, i) => (
|
||||
<TopicCard
|
||||
key={`${topic.title}-${i}`}
|
||||
topic={topic}
|
||||
onTrack={() => { void handleTrack(topic, i) }}
|
||||
isRemoving={removingIndex === i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ export function TabBar<T>({
|
|||
activeTabId,
|
||||
getTabTitle,
|
||||
getTabId,
|
||||
isProcessing,
|
||||
onSwitchTab,
|
||||
onCloseTab,
|
||||
layout = 'fill',
|
||||
|
|
@ -47,7 +46,6 @@ export function TabBar<T>({
|
|||
{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<T>({
|
|||
)}
|
||||
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
|
||||
>
|
||||
{processing && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-left">{title}</span>
|
||||
{(allowSingleTabClose || tabs.length > 1) && (
|
||||
<span
|
||||
|
|
|
|||
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
import type { OpenTrackModalDetail } from '@/extensions/track-block'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': '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 <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
|
||||
const [yaml, setYaml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('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<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Listen for the open event and seed modal state.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackModalDetail>
|
||||
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<z.infer<typeof TrackBlockSchema> | 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<string, unknown>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<div className="track-modal-header">
|
||||
<div className="track-modal-header-left">
|
||||
<div className="track-modal-icon-wrap">
|
||||
<Radio size={16} />
|
||||
</div>
|
||||
<div className="track-modal-title-col">
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogTitle className="track-modal-title">
|
||||
{trackId || 'Track'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="track-modal-subtitle">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
|
||||
{scheduleSummary.text}
|
||||
{eventMatchCriteria && triggerType === 'scheduled' && (
|
||||
<span className="track-modal-subtitle-sep">· also event-driven</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-modal-header-actions">
|
||||
<label className="track-modal-toggle">
|
||||
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
|
||||
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="track-modal-tabs">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="track-modal-body">
|
||||
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>}
|
||||
|
||||
{activeTab === 'what' && (
|
||||
<div className="track-modal-prose">
|
||||
{instruction
|
||||
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-modal-empty">No instruction set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && schedule && (
|
||||
<div className="track-modal-when">
|
||||
<div className="track-modal-when-headline">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
|
||||
<span>{scheduleSummary.text}</span>
|
||||
</div>
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
|
||||
{schedule.type === 'cron' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'window' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
|
||||
<dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'once' && (
|
||||
<>
|
||||
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="track-modal-prose">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-modal-empty">No event matching set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-details">
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
{lastRunId && (<>
|
||||
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
|
||||
</>)}
|
||||
{lastRunSummary && (<>
|
||||
<dt>Summary</dt><dd>{lastRunSummary}</dd>
|
||||
</>)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced (raw YAML) — all tabs */}
|
||||
<div className="track-modal-advanced">
|
||||
<button
|
||||
className="track-modal-advanced-toggle"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) {
|
||||
setRawDraft(yaml)
|
||||
setEditingRaw(true)
|
||||
} else {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<Code2 size={12} />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="track-modal-raw-editor">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="track-modal-textarea"
|
||||
/>
|
||||
<div className="track-modal-raw-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRaw}
|
||||
disabled={saving || rawDraft.trim() === yaml.trim()}
|
||||
>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — on Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-danger-zone">
|
||||
{confirmingDelete ? (
|
||||
<div className="track-modal-confirm">
|
||||
<span>Delete this track and its generated content?</span>
|
||||
<div className="track-modal-confirm-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Yes, delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="track-modal-delete-btn"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete track block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="track-modal-error">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="track-modal-footer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditWithCopilot}
|
||||
disabled={saving}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || saving}
|
||||
className="track-modal-run-btn"
|
||||
>
|
||||
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
|
@ -3,18 +3,10 @@ import { Slot } from "@radix-ui/react-slot"
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -28,17 +20,14 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|||
const SIDEBAR_WIDTH = 256 // 16rem in pixels
|
||||
const SIDEBAR_WIDTH_MIN = 200
|
||||
const SIDEBAR_WIDTH_MAX = 480
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_OFFSET = "0px" // Default offset for nested sidebars
|
||||
const SIDEBAR_AUTO_COLLAPSE_WIDTH = 760 // Auto-collapse when window narrower than this
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
sidebarWidth: number
|
||||
setSidebarWidth: (width: number) => void
|
||||
|
|
@ -70,8 +59,6 @@ function SidebarProvider({
|
|||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH)
|
||||
const [isResizing, setIsResizing] = React.useState(false)
|
||||
|
||||
|
|
@ -94,10 +81,20 @@ function SidebarProvider({
|
|||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Auto-collapse sidebar when window crosses below the threshold
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${SIDEBAR_AUTO_COLLAPSE_WIDTH - 1}px)`)
|
||||
const onChange = () => {
|
||||
if (mql.matches) setOpen(false)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [setOpen])
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
return setOpen((open) => !open)
|
||||
}, [setOpen])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
|
|
@ -108,16 +105,13 @@ function SidebarProvider({
|
|||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
sidebarWidth,
|
||||
setSidebarWidth,
|
||||
isResizing,
|
||||
setIsResizing,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, isResizing]
|
||||
[state, open, setOpen, toggleSidebar, sidebarWidth, isResizing]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -161,7 +155,7 @@ function Sidebar({
|
|||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { state } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
|
|
@ -178,34 +172,9 @@ function Sidebar({
|
|||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
className="group peer text-sidebar-foreground block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
|
|
@ -217,7 +186,7 @@ function Sidebar({
|
|||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
"[[data-resizing=false]_&]:transition-[width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
|
||||
"in-data-[resizing=false]:transition-[width] in-data-[resizing=false]:duration-200 in-data-[resizing=false]:ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
|
|
@ -228,10 +197,10 @@ function Sidebar({
|
|||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
"[[data-resizing=false]_&]:transition-[left,right,width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
|
||||
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width)",
|
||||
"in-data-[resizing=false]:transition-[left,right,width] in-data-[resizing=false]:duration-200 in-data-[resizing=false]:ease-linear",
|
||||
side === "left"
|
||||
? "left-[var(--sidebar-offset)] group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
|
||||
? "left-(--sidebar-offset) group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
|
|
@ -347,7 +316,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
"peer-data-[variant=inset]:m-2 peer-data-[variant=inset]:ml-0 peer-data-[variant=inset]:rounded-xl peer-data-[variant=inset]:shadow-sm peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -546,7 +515,7 @@ function SidebarMenuButton({
|
|||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
const { state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
|
|
@ -575,7 +544,7 @@ function SidebarMenuButton({
|
|||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
hidden={state !== "collapsed"}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { mergeAttributes, Node as TiptapNode } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
|
@ -9,12 +9,15 @@ function formatTime(dateStr: string): string {
|
|||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getDateParts(dateStr: string): { day: number; month: string; weekday: string } {
|
||||
function getDateParts(dateStr: string): { day: number; month: string; weekday: string; isToday: boolean } {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||
return {
|
||||
day: d.getDate(),
|
||||
month: d.toLocaleDateString([], { month: 'long' }),
|
||||
weekday: d.toLocaleDateString([], { weekday: 'short' }),
|
||||
month: d.toLocaleDateString([], { month: 'short' }).toUpperCase(),
|
||||
weekday: d.toLocaleDateString([], { weekday: 'short' }).toUpperCase(),
|
||||
isToday,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +65,8 @@ interface ResolvedEvent {
|
|||
conferenceLink?: string
|
||||
}
|
||||
|
||||
const EVENT_BAR_COLOR = '#7ec8c8'
|
||||
const GCAL_EVENT_COLOR = '#039be5'
|
||||
const GCAL_TODAY_COLOR = '#1a73e8'
|
||||
|
||||
function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: {
|
||||
onJoinAndNotes: () => void
|
||||
|
|
@ -74,7 +78,8 @@ function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: {
|
|||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
const target = e.target
|
||||
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
|
|
@ -272,11 +277,8 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string,
|
|||
<div className="calendar-block-date-left">
|
||||
{parts ? (
|
||||
<>
|
||||
<span className="calendar-block-day">{parts.day}</span>
|
||||
<div className="calendar-block-month-weekday">
|
||||
<span className="calendar-block-month">{parts.month}</span>
|
||||
<span className="calendar-block-weekday">{parts.weekday}</span>
|
||||
</div>
|
||||
<span className="calendar-block-weekday" style={parts.isToday ? { color: GCAL_TODAY_COLOR } : undefined}>{parts.weekday}</span>
|
||||
<span className={`calendar-block-day${parts.isToday ? ' calendar-block-day-today' : ''}`}>{parts.day}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="calendar-block-day">?</span>
|
||||
|
|
@ -287,16 +289,13 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string,
|
|||
<div
|
||||
key={event._idx}
|
||||
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
|
||||
style={{ backgroundColor: GCAL_EVENT_COLOR }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
|
||||
>
|
||||
<div
|
||||
className="calendar-block-event-bar"
|
||||
style={{ backgroundColor: EVENT_BAR_COLOR }}
|
||||
/>
|
||||
<div className="calendar-block-event-content">
|
||||
<div className="calendar-block-event-title">
|
||||
{event.summary || 'Untitled event'}
|
||||
{event.summary || '(No title)'}
|
||||
</div>
|
||||
<div className="calendar-block-event-time">
|
||||
{getTimeRange(event)}
|
||||
|
|
@ -322,7 +321,7 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string,
|
|||
)
|
||||
}
|
||||
|
||||
export const CalendarBlockExtension = Node.create({
|
||||
export const CalendarBlockExtension = TiptapNode.create({
|
||||
name: 'calendarBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
|
|
@ -17,8 +18,16 @@ function formatEmailDate(dateStr: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase()
|
||||
/** Extract just the name part from "Name <email>" format */
|
||||
function senderFirstName(from: string): string {
|
||||
const name = from.replace(/<.*>/, '').trim()
|
||||
return name.split(/\s+/)[0] || name
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingEmailDraft?: { prompt: string }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email Block ---
|
||||
|
|
@ -40,11 +49,12 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
const hasDraft = !!config?.draft_response
|
||||
const hasPastSummary = !!config?.past_summary
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
// Local draft state for editing
|
||||
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||
const [contextExpanded, setContextExpanded] = useState(false)
|
||||
const [emailExpanded, setEmailExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync draft from external changes
|
||||
|
|
@ -70,40 +80,23 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
} catch { /* ignore */ }
|
||||
}, [raw, updateAttributes])
|
||||
|
||||
const generateResponse = useCallback(async () => {
|
||||
if (!config || generating) return
|
||||
setGenerating(true)
|
||||
try {
|
||||
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, unknown>) => Promise<{ response?: string }> } }).ipc
|
||||
// Build context for the agent
|
||||
let noteContent = `# Email: ${config.subject || 'No subject'}\n\n`
|
||||
noteContent += `**From:** ${config.from || 'Unknown'}\n`
|
||||
noteContent += `**Date:** ${config.date || 'Unknown'}\n\n`
|
||||
noteContent += `## Latest email\n\n${config.latest_email}\n\n`
|
||||
if (config.past_summary) {
|
||||
noteContent += `## Earlier conversation summary\n\n${config.past_summary}\n\n`
|
||||
}
|
||||
|
||||
const result = await ipc.invoke('inline-task:process', {
|
||||
instruction: `Draft a concise, professional response to this email. Return only the email body text, no subject line or headers.`,
|
||||
noteContent,
|
||||
notePath: '',
|
||||
})
|
||||
|
||||
if (result.response) {
|
||||
// Clean up the response — strip any markdown headers the agent may add
|
||||
const cleaned = result.response.replace(/^#+\s+.*\n*/gm, '').trim()
|
||||
setDraftBody(cleaned)
|
||||
// Update the block data to include the draft
|
||||
const current = JSON.parse(raw) as Record<string, unknown>
|
||||
updateAttributes({ data: JSON.stringify({ ...current, draft_response: cleaned }) })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[email-block] Failed to generate response:', err)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
if (!config) return
|
||||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
}
|
||||
}, [config, generating, raw, updateAttributes])
|
||||
prompt += `.\n\n`
|
||||
prompt += `**From:** ${config.from || 'Unknown'}\n`
|
||||
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) {
|
||||
prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
}
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config, draftBody])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
|
|
@ -120,144 +113,113 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
|
||||
// --- Render: Draft mode (draft_response present) ---
|
||||
if (hasDraft) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/* Draft header */}
|
||||
{config.to && (
|
||||
<div className="email-draft-block-header">
|
||||
<div className="email-draft-block-field">
|
||||
<span className="email-draft-block-label">To</span>
|
||||
<span className="email-draft-block-value">{config.to}</span>
|
||||
</div>
|
||||
{config.subject && (
|
||||
<div className="email-draft-block-field">
|
||||
<span className="email-draft-block-label">Subject</span>
|
||||
<span className="email-draft-block-value">{config.subject}</span>
|
||||
// Build summary: use explicit summary, or auto-generate from sender + subject
|
||||
const summary = config.summary
|
||||
|| (config.from && config.subject
|
||||
? `${senderFirstName(config.from)} reached out about ${config.subject}`
|
||||
: config.subject || 'New email')
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
{/* Header: Email badge */}
|
||||
<div className="email-block-badge">
|
||||
<Mail size={13} />
|
||||
Email
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="email-block-summary">{summary}</div>
|
||||
|
||||
{/* Expandable email details */}
|
||||
<button
|
||||
className="email-block-expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{emailExpanded ? 'Hide email' : 'Show email'}
|
||||
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
|
||||
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
|
||||
</button>
|
||||
|
||||
{emailExpanded && (
|
||||
<div className="email-block-email-details">
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
<div className="email-block-sender-info">
|
||||
<div className="email-block-sender-row">
|
||||
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Editable draft body */}
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
className="email-draft-block-body-input"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onBlur={() => commitDraft(draftBody)}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
{/* Action buttons */}
|
||||
<div className="email-draft-block-actions">
|
||||
{(hasPastSummary || config.latest_email) && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setContextExpanded(!contextExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${contextExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{contextExpanded ? 'Hide' : 'Show'} context
|
||||
</button>
|
||||
{hasPastSummary && (
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-context-label">Earlier conversation</div>
|
||||
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft section */}
|
||||
{hasDraft && (
|
||||
<div className="email-block-draft-section">
|
||||
<div className="email-block-draft-label">Draft reply</div>
|
||||
<textarea
|
||||
key={resolvedTheme}
|
||||
ref={bodyRef}
|
||||
className="email-draft-block-body-input"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onBlur={() => commitDraft(draftBody)}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="email-block-actions">
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={draftWithAssistant}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||
</button>
|
||||
{hasDraft && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(draftBody)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
navigator.clipboard.writeText(draftBody).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {
|
||||
// Fallback for Electron contexts where clipboard API may fail
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = draftBody
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied!' : 'Copy draft'}
|
||||
</button>
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(draftBody)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
window.open(gmailUrl, '_blank')
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Reply in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Context: latest email + past summary */}
|
||||
{contextExpanded && (
|
||||
<div className="email-block-context">
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
{config.from && <div className="email-block-avatar">{getInitials(config.from)}</div>}
|
||||
<div className="email-block-sender-info">
|
||||
{config.from && <div className="email-block-sender-name">{config.from}</div>}
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasPastSummary && (
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-context-label">Earlier conversation</div>
|
||||
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Render: Read mode (no draft_response) ---
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.subject && <div className="email-block-subject">{config.subject}</div>}
|
||||
{/* Latest email message */}
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
{config.from && <div className="email-block-avatar">{getInitials(config.from)}</div>}
|
||||
<div className="email-block-sender-info">
|
||||
{config.from && <div className="email-block-sender-name">{config.from}</div>}
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="email-draft-block-actions">
|
||||
{hasPastSummary && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setContextExpanded(!contextExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${contextExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{contextExpanded ? 'Hide' : 'Show'} context
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-generate-btn"
|
||||
onClick={generateResponse}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
|
||||
{generating ? 'Generating...' : 'Generate response'}
|
||||
</button>
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
|
|
@ -268,15 +230,6 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Past summary context */}
|
||||
{contextExpanded && hasPastSummary && (
|
||||
<div className="email-block-context">
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-context-label">Earlier conversation</div>
|
||||
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
|
|
|
|||
256
apps/x/apps/renderer/src/extensions/iframe-block.tsx
Normal file
256
apps/x/apps/renderer/src/extensions/iframe-block.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Globe, X } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height'
|
||||
const IFRAME_HEIGHT_CACHE_PREFIX = 'rowboat:iframe-height:'
|
||||
const DEFAULT_IFRAME_HEIGHT = 560
|
||||
const MIN_IFRAME_HEIGHT = 240
|
||||
const HEIGHT_UPDATE_THRESHOLD = 4
|
||||
const AUTO_RESIZE_SETTLE_MS = 160
|
||||
const LOAD_FALLBACK_READY_MS = 180
|
||||
const DEFAULT_IFRAME_ALLOW = [
|
||||
'accelerometer',
|
||||
'autoplay',
|
||||
'camera',
|
||||
'clipboard-read',
|
||||
'clipboard-write',
|
||||
'display-capture',
|
||||
'encrypted-media',
|
||||
'fullscreen',
|
||||
'geolocation',
|
||||
'microphone',
|
||||
].join('; ')
|
||||
|
||||
function getIframeHeightCacheKey(url: string): string {
|
||||
return `${IFRAME_HEIGHT_CACHE_PREFIX}${url}`
|
||||
}
|
||||
|
||||
function readCachedIframeHeight(url: string, fallbackHeight: number): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getIframeHeightCacheKey(url))
|
||||
if (!raw) return fallbackHeight
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return fallbackHeight
|
||||
return Math.max(MIN_IFRAME_HEIGHT, parsed)
|
||||
} catch {
|
||||
return fallbackHeight
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedIframeHeight(url: string, height: number): void {
|
||||
try {
|
||||
window.localStorage.setItem(getIframeHeightCacheKey(url), String(height))
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function parseIframeHeightMessage(event: MessageEvent): { height: number } | null {
|
||||
const data = event.data
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
const candidate = data as { type?: unknown; height?: unknown }
|
||||
if (candidate.type !== IFRAME_HEIGHT_MESSAGE) return null
|
||||
if (typeof candidate.height !== 'number' || !Number.isFinite(candidate.height)) return null
|
||||
|
||||
return {
|
||||
height: Math.max(MIN_IFRAME_HEIGHT, Math.ceil(candidate.height)),
|
||||
}
|
||||
}
|
||||
|
||||
function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.IframeBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.IframeBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||
<div className="iframe-block-card iframe-block-error">
|
||||
<Globe size={16} />
|
||||
<span>Invalid iframe block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleTitle = config.title?.trim() || ''
|
||||
const title = visibleTitle || 'Embedded page'
|
||||
const allow = config.allow || DEFAULT_IFRAME_ALLOW
|
||||
const initialHeight = config.height ?? DEFAULT_IFRAME_HEIGHT
|
||||
const [frameHeight, setFrameHeight] = useState(() => readCachedIframeHeight(config.url, initialHeight))
|
||||
const [frameReady, setFrameReady] = useState(false)
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
||||
const loadFallbackTimerRef = useRef<number | null>(null)
|
||||
const autoResizeReadyTimerRef = useRef<number | null>(null)
|
||||
const frameReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setFrameHeight(readCachedIframeHeight(config.url, initialHeight))
|
||||
setFrameReady(false)
|
||||
frameReadyRef.current = false
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
loadFallbackTimerRef.current = null
|
||||
}
|
||||
if (autoResizeReadyTimerRef.current !== null) {
|
||||
window.clearTimeout(autoResizeReadyTimerRef.current)
|
||||
autoResizeReadyTimerRef.current = null
|
||||
}
|
||||
}, [config.url, initialHeight, raw])
|
||||
|
||||
useEffect(() => {
|
||||
frameReadyRef.current = frameReady
|
||||
}, [frameReady])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow
|
||||
if (!iframeWindow || event.source !== iframeWindow) return
|
||||
|
||||
const message = parseIframeHeightMessage(event)
|
||||
if (!message) return
|
||||
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
loadFallbackTimerRef.current = null
|
||||
}
|
||||
if (autoResizeReadyTimerRef.current !== null) {
|
||||
window.clearTimeout(autoResizeReadyTimerRef.current)
|
||||
}
|
||||
writeCachedIframeHeight(config.url, message.height)
|
||||
setFrameHeight((currentHeight) => (
|
||||
Math.abs(currentHeight - message.height) < HEIGHT_UPDATE_THRESHOLD ? currentHeight : message.height
|
||||
))
|
||||
|
||||
if (!frameReadyRef.current) {
|
||||
autoResizeReadyTimerRef.current = window.setTimeout(() => {
|
||||
setFrameReady(true)
|
||||
frameReadyRef.current = true
|
||||
autoResizeReadyTimerRef.current = null
|
||||
}, AUTO_RESIZE_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [config.url])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
}
|
||||
if (autoResizeReadyTimerRef.current !== null) {
|
||||
window.clearTimeout(autoResizeReadyTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||
<div className="iframe-block-card">
|
||||
<button
|
||||
className="iframe-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete iframe block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{visibleTitle && <div className="iframe-block-title">{visibleTitle}</div>}
|
||||
<div
|
||||
className={`iframe-block-frame-shell${frameReady ? ' iframe-block-frame-shell-ready' : ' iframe-block-frame-shell-loading'}`}
|
||||
style={{ height: frameHeight }}
|
||||
>
|
||||
{!frameReady && (
|
||||
<div className="iframe-block-loading-overlay" aria-hidden="true">
|
||||
<div className="iframe-block-loading-bar" />
|
||||
<div className="iframe-block-loading-copy">Loading embed…</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={config.url}
|
||||
title={title}
|
||||
className="iframe-block-frame"
|
||||
loading="lazy"
|
||||
onLoad={() => {
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
}
|
||||
loadFallbackTimerRef.current = window.setTimeout(() => {
|
||||
setFrameReady(true)
|
||||
loadFallbackTimerRef.current = null
|
||||
}, LOAD_FALLBACK_READY_MS)
|
||||
}}
|
||||
allow={allow}
|
||||
allowFullScreen
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const IframeBlockExtension = Node.create({
|
||||
name: 'iframeBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-iframe')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'iframe-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(IframeBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```iframe\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
86
apps/x/apps/renderer/src/extensions/mermaid-block.tsx
Normal file
86
apps/x/apps/renderer/src/extensions/mermaid-block.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, GitBranch } from 'lucide-react'
|
||||
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
||||
|
||||
function MermaidBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const source = (node.attrs.data as string) || ''
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="mermaid-block-wrapper" data-type="mermaid-block">
|
||||
<div className="mermaid-block-card">
|
||||
<button
|
||||
className="mermaid-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete mermaid block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{source ? (
|
||||
<MermaidRenderer source={source} />
|
||||
) : (
|
||||
<div className="mermaid-block-empty">
|
||||
<GitBranch size={16} />
|
||||
<span>Empty mermaid block</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const MermaidBlockExtension = Node.create({
|
||||
name: 'mermaidBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-mermaid')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MermaidBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```mermaid\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
145
apps/x/apps/renderer/src/extensions/prompt-block.tsx
Normal file
145
apps/x/apps/renderer/src/extensions/prompt-block.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PromptBlockSchema } from '@x/shared/dist/prompt-block.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
function PromptBlockView({ node, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
|
||||
const prompt = useMemo<z.infer<typeof PromptBlockSchema> | null>(() => {
|
||||
try {
|
||||
return PromptBlockSchema.parse(parseYaml(raw))
|
||||
} catch { return null }
|
||||
}, [raw])
|
||||
|
||||
const notePath = extension.options.notePath
|
||||
|
||||
const handleRun = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!prompt) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-prompt', {
|
||||
detail: {
|
||||
instruction: prompt.instruction,
|
||||
label: prompt.label,
|
||||
filePath: notePath,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleRun(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
return (
|
||||
<NodeViewWrapper data-type="prompt-block">
|
||||
<div className="my-2 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
Invalid prompt block — expected YAML with <code>label</code> and <code>instruction</code>.
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-type="prompt-block">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleRun}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={prompt.instruction}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium">{prompt.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{truncate(prompt.instruction, 80)}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptBlockExtension = Node.create({
|
||||
name: 'promptBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-prompt')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prompt-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PromptBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```prompt\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
179
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
179
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, Loader2 } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
// Detail shape for the open-track-modal window event. Defined here so the
|
||||
// consumer (TrackModal) can import it without a circular dependency.
|
||||
export type OpenTrackModalDetail = {
|
||||
trackId: string
|
||||
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
|
||||
filePath: string
|
||||
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
|
||||
initialYaml: string
|
||||
/** Invoked after a successful IPC delete so the editor can remove the node. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chip (display-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrackBlockView({ node, deleteNode, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(cleaned))
|
||||
} catch(error) { console.error('error', error); return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!trackId || !notePath) return
|
||||
const detail: OpenTrackModalDetail = {
|
||||
trackId,
|
||||
filePath: notePath,
|
||||
initialYaml: raw,
|
||||
onDeleted: () => deleteNode(),
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||
'rowboat:open-track-modal',
|
||||
{ detail },
|
||||
))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="track-block-chip-wrapper"
|
||||
data-type="track-block"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||
>
|
||||
{isRunning
|
||||
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
|
||||
: <Radio size={13} className="track-block-chip-icon" />}
|
||||
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||
{instruction && (
|
||||
<span className="track-block-chip-sep">·</span>
|
||||
)}
|
||||
{instruction && (
|
||||
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||
)}
|
||||
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-track')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TrackBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```track\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
90
apps/x/apps/renderer/src/extensions/track-target.tsx
Normal file
90
apps/x/apps/renderer/src/extensions/track-target.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Track target markers — two Tiptap atom nodes that represent the open and
|
||||
* close HTML comment markers bracketing a track's output region on disk:
|
||||
*
|
||||
* <!--track-target:ID--> → TrackTargetOpenExtension
|
||||
* content in between → regular Tiptap nodes (paragraphs, lists,
|
||||
* custom blocks, whatever tiptap-markdown parses)
|
||||
* <!--/track-target:ID--> → TrackTargetCloseExtension
|
||||
*
|
||||
* The markers are *semantic boundaries*, not a UI container. Content between
|
||||
* them is real, editable document content — fully rendered by the existing
|
||||
* extension set and freely editable by the user. The backend's updateContent()
|
||||
* in fileops.ts still locates the region on disk by these comment markers.
|
||||
*
|
||||
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
|
||||
* regex replace, converting each comment into a placeholder div that these
|
||||
* extensions' parseHTML rules pick up. No content capture.
|
||||
*
|
||||
* Save path: both Tiptap's built-in markdown serializer
|
||||
* (`addStorage().markdown.serialize`) AND the app's custom serializer
|
||||
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
|
||||
* back out — they must stay in sync.
|
||||
*/
|
||||
|
||||
type MarkerVariant = 'open' | 'close'
|
||||
|
||||
function buildMarkerExtension(variant: MarkerVariant) {
|
||||
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
|
||||
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
|
||||
const commentFor = (id: string) =>
|
||||
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
|
||||
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
trackId: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${htmlType}"]`,
|
||||
getAttrs(el) {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return { trackId: el.getAttribute('data-track-id') ?? '' }
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': htmlType,
|
||||
'data-track-id': (node.attrs.trackId as string) ?? '',
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
|
||||
node: { attrs: { trackId: string } },
|
||||
) {
|
||||
state.write(commentFor(node.attrs.trackId ?? ''))
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled via preprocessTrackTargets → parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const TrackTargetOpenExtension = buildMarkerExtension('open')
|
||||
export const TrackTargetCloseExtension = buildMarkerExtension('close')
|
||||
177
apps/x/apps/renderer/src/extensions/transcript-block.tsx
Normal file
177
apps/x/apps/renderer/src/extensions/transcript-block.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ChevronDown, FileText } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
interface TranscriptEntry {
|
||||
speaker: string
|
||||
text: string
|
||||
}
|
||||
|
||||
function parseTranscript(raw: string): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = []
|
||||
const lines = raw.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
// Match **Speaker Name:** text or **You:** text
|
||||
const match = trimmed.match(/^\*\*(.+?):\*\*\s*(.*)$/)
|
||||
if (match) {
|
||||
entries.push({ speaker: match[1], text: match[2] })
|
||||
} else if (entries.length > 0) {
|
||||
// Continuation line — append to last entry
|
||||
entries[entries.length - 1].text += ' ' + trimmed
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function speakerColor(speaker: string): string {
|
||||
// Simple hash to pick a consistent color per speaker
|
||||
let hash = 0
|
||||
for (let i = 0; i < speaker.length; i++) {
|
||||
hash = speaker.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const colors = [
|
||||
'#3b82f6', // blue
|
||||
'#06b6d4', // cyan
|
||||
'#6366f1', // indigo
|
||||
'#8b5cf6', // purple
|
||||
'#0ea5e9', // sky
|
||||
'#2563eb', // blue darker
|
||||
'#7c3aed', // violet
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
function TranscriptBlockView({ node, getPos, editor }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
getPos: () => number | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: any
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.TranscriptBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.TranscriptBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
// Auto-detect: expand if this is the first real block (live recording),
|
||||
// collapse if there's other content above (notes have been generated)
|
||||
const isFirstBlock = useMemo(() => {
|
||||
try {
|
||||
const pos = getPos()
|
||||
if (pos === undefined) return false
|
||||
const firstChild = editor?.state?.doc?.firstChild
|
||||
if (!firstChild) return true
|
||||
// If the transcript block is right after the first node (heading), it's the main content
|
||||
return pos <= (firstChild.nodeSize ?? 0) + 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [getPos, editor])
|
||||
|
||||
const [expanded, setExpanded] = useState(isFirstBlock)
|
||||
|
||||
const entries = useMemo(() => {
|
||||
if (!config) return []
|
||||
return parseTranscript(config.transcript)
|
||||
}, [config])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
|
||||
<div className="transcript-block-card transcript-block-error">
|
||||
<FileText size={16} />
|
||||
<span>Invalid transcript block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
|
||||
<div className="transcript-block-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="transcript-block-toggle"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={14} className={`transcript-block-chevron ${expanded ? 'transcript-block-chevron-open' : ''}`} />
|
||||
<FileText size={14} />
|
||||
<span>Raw transcript</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="transcript-block-content">
|
||||
{entries.length > 0 ? (
|
||||
entries.map((entry, i) => (
|
||||
<div key={i} className="transcript-entry">
|
||||
<span className="transcript-speaker" style={{ color: speakerColor(entry.speaker) }}>
|
||||
{entry.speaker}
|
||||
</span>
|
||||
<span className="transcript-text">{entry.text}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="transcript-raw">{config.transcript}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TranscriptBlockExtension = Node.create({
|
||||
name: 'transcriptBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: { default: '{}' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-transcript')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'transcript-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TranscriptBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```transcript\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
3
apps/x/apps/renderer/src/global.d.ts
vendored
3
apps/x/apps/renderer/src/global.d.ts
vendored
|
|
@ -35,8 +35,9 @@ declare global {
|
|||
};
|
||||
electronUtils: {
|
||||
getPathForFile: (file: File) => string;
|
||||
getZoomFactor: () => number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
export { };
|
||||
|
|
|
|||
72
apps/x/apps/renderer/src/hooks/use-track-status.ts
Normal file
72
apps/x/apps/renderer/src/hooks/use-track-status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TrackEvent } from '@x/shared/dist/track-block.js';
|
||||
|
||||
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface TrackState {
|
||||
status: TrackRunStatus;
|
||||
runId?: string;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Module-level store — shared across all hook consumers, subscribed once
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change
|
||||
let store = new Map<string, TrackState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
|
||||
store = new Map(store);
|
||||
fn(store);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function ensureSubscription() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
|
||||
const key = `${event.trackId}:${event.filePath}`;
|
||||
|
||||
if (event.type === 'track_run_start') {
|
||||
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
|
||||
} else if (event.type === 'track_run_complete') {
|
||||
updateStore(s => s.set(key, {
|
||||
status: event.error ? 'error' : 'done',
|
||||
runId: event.runId,
|
||||
summary: event.summary ?? null,
|
||||
error: event.error ?? null,
|
||||
}));
|
||||
// Auto-clear after 5 seconds
|
||||
setTimeout(() => {
|
||||
updateStore(s => s.delete(key));
|
||||
}, 5000);
|
||||
}
|
||||
}) as (event: z.infer<typeof TrackEvent>) => void);
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
ensureSubscription();
|
||||
listeners.add(onStoreChange);
|
||||
return () => { listeners.delete(onStoreChange); };
|
||||
}
|
||||
|
||||
function getSnapshot(): Map<string, TrackState> {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map of all track run states, keyed by "trackId:filePath".
|
||||
*
|
||||
* Usage in a track block component:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
|
||||
*
|
||||
* Usage for a global indicator:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
|
||||
*/
|
||||
export function useTrackStatus(): Map<string, TrackState> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
74
apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts
Normal file
74
apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useEffect } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
/**
|
||||
* Identifies the user in PostHog when signed into Rowboat,
|
||||
* and sets user properties for connected OAuth providers.
|
||||
* Call once at the App level.
|
||||
*/
|
||||
export function useAnalyticsIdentity() {
|
||||
// On mount: check current OAuth state and identify if signed in
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
|
||||
// Identify if Rowboat account is connected
|
||||
const rowboat = config.rowboat
|
||||
if (rowboat?.connected && rowboat?.userId) {
|
||||
posthog.identify(rowboat.userId)
|
||||
}
|
||||
|
||||
// Set provider connection flags
|
||||
const providers = ['gmail', 'calendar', 'slack', 'rowboat']
|
||||
const props: Record<string, boolean> = { signed_in: !!rowboat?.connected }
|
||||
for (const p of providers) {
|
||||
props[`${p}_connected`] = !!config[p]?.connected
|
||||
}
|
||||
posthog.people.set(props)
|
||||
|
||||
// Count notes for total_notes property
|
||||
try {
|
||||
const entries = await window.ipc.invoke('workspace:readdir', { path: '' })
|
||||
let totalNotes = 0
|
||||
if (entries) {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'dir') {
|
||||
try {
|
||||
const sub = await window.ipc.invoke('workspace:readdir', { path: `${entry.name}` })
|
||||
totalNotes += sub?.length ?? 0
|
||||
} catch {
|
||||
// skip inaccessible dirs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
posthog.people.set({ total_notes: totalNotes })
|
||||
} catch {
|
||||
// workspace may not be available
|
||||
}
|
||||
} catch {
|
||||
// oauth state unavailable
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// Listen for OAuth connect/disconnect events to update identity
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (!event.success) return
|
||||
|
||||
// If Rowboat provider connected, identify user
|
||||
if (event.provider === 'rowboat' && event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
posthog.people.set({ signed_in: true })
|
||||
}
|
||||
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ interface BillingInfo {
|
|||
userId: string | null
|
||||
subscriptionPlan: string | null
|
||||
subscriptionStatus: string | null
|
||||
trialExpiresAt: string | null
|
||||
sanctionedCredits: number
|
||||
availableCredits: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { setGoogleCredentials, clearGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
|
|
@ -318,14 +318,14 @@ export function useConnectors(active: boolean) {
|
|||
}, [startGmailConnect])
|
||||
|
||||
// OAuth connect/disconnect
|
||||
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 || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`))
|
||||
|
|
@ -347,23 +347,18 @@ export function useConnectors(active: boolean) {
|
|||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
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)
|
||||
setGoogleClientIdDescription(undefined)
|
||||
startConnect('google', clientId)
|
||||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
|
|
@ -377,7 +372,7 @@ export function useConnectors(active: boolean) {
|
|||
|
||||
if (result.success) {
|
||||
if (provider === 'google') {
|
||||
clearGoogleClientId()
|
||||
clearGoogleCredentials()
|
||||
}
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`)
|
||||
|
|
@ -562,7 +557,7 @@ export function useConnectors(active: boolean) {
|
|||
handleDisconnect,
|
||||
startConnect,
|
||||
|
||||
// Google client ID modal
|
||||
// Google credentials modal
|
||||
googleClientIdOpen,
|
||||
setGoogleClientIdOpen,
|
||||
googleClientIdDescription,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||
|
||||
|
|
@ -58,7 +60,7 @@ export interface CalendarEventMeta {
|
|||
}
|
||||
|
||||
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
|
||||
const noteTitle = calendarEvent?.summary || 'Meeting note';
|
||||
const noteTitle = calendarEvent?.summary || 'Meeting Notes';
|
||||
const lines = [
|
||||
'---',
|
||||
'type: meeting',
|
||||
|
|
@ -87,13 +89,18 @@ function formatTranscript(entries: TranscriptEntry[], date: string, calendarEven
|
|||
`# ${noteTitle}`,
|
||||
'',
|
||||
);
|
||||
// Build the raw transcript text
|
||||
const transcriptLines: string[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||
lines.push('');
|
||||
transcriptLines.push('');
|
||||
}
|
||||
lines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
|
||||
lines.push('');
|
||||
transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
|
||||
transcriptLines.push('');
|
||||
}
|
||||
const transcriptText = transcriptLines.join('\n').trim();
|
||||
const transcriptData = JSON.stringify({ transcript: transcriptText });
|
||||
lines.push('```transcript', transcriptData, '```');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +108,7 @@ function formatTranscript(entries: TranscriptEntry[], date: string, calendarEven
|
|||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||
const { refresh: refreshRowboatAccount } = useRowboatAccount();
|
||||
const [state, setState] = useState<MeetingTranscriptionState>('idle');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | null>(null);
|
||||
|
|
@ -184,47 +192,83 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
if (state !== 'idle') return null;
|
||||
setState('connecting');
|
||||
|
||||
// Detect headphones vs speakers
|
||||
const usingHeadphones = await detectHeadphones();
|
||||
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
|
||||
|
||||
// Get Deepgram token
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
const result = await window.ipc.invoke('voice:getDeepgramToken', null);
|
||||
if (result) {
|
||||
console.log('[meeting] Using proxy token');
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['bearer', result.token]);
|
||||
} else {
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (!config?.deepgram) {
|
||||
console.error('[meeting] No Deepgram config available');
|
||||
setState('idle');
|
||||
return null;
|
||||
// Run independent setup steps in parallel for faster startup
|
||||
const [headphoneResult, wsResult, micResult, systemResult] = await Promise.allSettled([
|
||||
// 1. Detect headphones vs speakers
|
||||
detectHeadphones(),
|
||||
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
|
||||
(async () => {
|
||||
const account = await refreshRowboatAccount();
|
||||
let ws: WebSocket;
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
) {
|
||||
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
|
||||
console.log('[meeting] Using Rowboat WebSocket');
|
||||
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
|
||||
} else {
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (!config?.deepgram) {
|
||||
throw new Error('No Deepgram config available');
|
||||
}
|
||||
console.log('[meeting] Using Deepgram API key');
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
|
||||
}
|
||||
console.log('[meeting] Using API key');
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[meeting] Failed to get Deepgram token:', err);
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
wsRef.current = ws;
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
ws.onopen = () => resolve(true);
|
||||
ws.onerror = () => resolve(false);
|
||||
setTimeout(() => resolve(false), 5000);
|
||||
});
|
||||
if (!ok) throw new Error('WebSocket failed to connect');
|
||||
console.log('[meeting] WebSocket connected');
|
||||
return ws;
|
||||
})(),
|
||||
// 3. Get mic stream
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
}),
|
||||
// 4. Get system audio via getDisplayMedia (loopback)
|
||||
(async () => {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
|
||||
stream.getVideoTracks().forEach(t => t.stop());
|
||||
if (stream.getAudioTracks().length === 0) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
throw new Error('No audio track from getDisplayMedia');
|
||||
}
|
||||
console.log('[meeting] System audio captured');
|
||||
return stream;
|
||||
})(),
|
||||
]);
|
||||
|
||||
// Wait for WS open
|
||||
const wsOk = await new Promise<boolean>((resolve) => {
|
||||
ws.onopen = () => resolve(true);
|
||||
ws.onerror = () => resolve(false);
|
||||
setTimeout(() => resolve(false), 5000);
|
||||
});
|
||||
if (!wsOk) {
|
||||
console.error('[meeting] WebSocket failed to connect');
|
||||
// Check for failures — clean up any successful resources if something failed
|
||||
const failed = wsResult.status === 'rejected'
|
||||
|| micResult.status === 'rejected'
|
||||
|| systemResult.status === 'rejected';
|
||||
|
||||
if (failed) {
|
||||
if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason);
|
||||
if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason);
|
||||
if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason);
|
||||
// Clean up any resources that did succeed
|
||||
if (wsResult.status === 'fulfilled') { wsResult.value.close(); }
|
||||
if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); }
|
||||
if (systemResult.status === 'fulfilled') { systemResult.value.getTracks().forEach(t => t.stop()); }
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
console.log('[meeting] WebSocket connected');
|
||||
|
||||
const usingHeadphones = headphoneResult.status === 'fulfilled' ? headphoneResult.value : false;
|
||||
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
|
||||
|
||||
const ws = wsResult.value;
|
||||
wsRef.current = ws;
|
||||
|
||||
// Set up WS message handler
|
||||
transcriptRef.current = [];
|
||||
|
|
@ -275,43 +319,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
wsRef.current = null;
|
||||
};
|
||||
|
||||
// Get mic stream
|
||||
let micStream: MediaStream;
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[meeting] Microphone access denied:', err);
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
const micStream = micResult.value;
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
// Get system audio via getDisplayMedia (loopback)
|
||||
let systemStream: MediaStream;
|
||||
try {
|
||||
systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
|
||||
systemStream.getVideoTracks().forEach(t => t.stop());
|
||||
} catch (err) {
|
||||
console.error('[meeting] System audio access denied:', err);
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
if (systemStream.getAudioTracks().length === 0) {
|
||||
console.error('[meeting] No audio track from getDisplayMedia');
|
||||
systemStream.getTracks().forEach(t => t.stop());
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
console.log('[meeting] System audio captured');
|
||||
const systemStream = systemResult.value;
|
||||
systemStreamRef.current = systemStream;
|
||||
|
||||
// ----- Audio pipeline -----
|
||||
|
|
@ -389,7 +400,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
|
||||
setState('recording');
|
||||
return notePath;
|
||||
}, [state, cleanup, scheduleDebouncedWrite]);
|
||||
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (state !== 'recording') return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from '@/lib/toast';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
/**
|
||||
* Hook for managing OAuth connection state for a specific provider
|
||||
|
|
@ -40,6 +42,8 @@ export function useOAuth(provider: string) {
|
|||
setIsLoading(false);
|
||||
|
||||
if (event.success) {
|
||||
analytics.oauthConnected(provider);
|
||||
posthog.people.set({ [`${provider}_connected`]: true });
|
||||
toast(`Successfully connected to ${provider}`, 'success');
|
||||
// Refresh connection status to ensure consistency
|
||||
checkConnection();
|
||||
|
|
@ -51,10 +55,10 @@ export function useOAuth(provider: string) {
|
|||
return cleanup;
|
||||
}, [provider, checkConnection]);
|
||||
|
||||
const connect = useCallback(async (clientId?: string) => {
|
||||
const connect = useCallback(async (credentials?: { clientId: string; clientSecret: string }) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
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) {
|
||||
// OAuth flow started - keep isConnecting state, wait for event
|
||||
// Event listener will handle the actual completion
|
||||
|
|
@ -75,6 +79,8 @@ export function useOAuth(provider: string) {
|
|||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:disconnect', { provider });
|
||||
if (result.success) {
|
||||
analytics.oauthDisconnected(provider);
|
||||
posthog.people.set({ [`${provider}_connected`]: false });
|
||||
toast(`Disconnected from ${provider}`, 'success');
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
|
|
|
|||
65
apps/x/apps/renderer/src/hooks/useRowboatAccount.ts
Normal file
65
apps/x/apps/renderer/src/hooks/useRowboatAccount.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { z } from 'zod';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js';
|
||||
|
||||
|
||||
interface RowboatAccountState {
|
||||
signedIn: boolean;
|
||||
accessToken: string | null;
|
||||
config: z.infer<typeof RowboatApiConfig> | null;
|
||||
}
|
||||
|
||||
export type RowboatAccountSnapshot = RowboatAccountState;
|
||||
|
||||
const DEFAULT_STATE: RowboatAccountState = {
|
||||
signedIn: false,
|
||||
accessToken: null,
|
||||
config: null,
|
||||
};
|
||||
|
||||
export function useRowboatAccount() {
|
||||
const [state, setState] = useState<RowboatAccountState>(DEFAULT_STATE);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const refresh = useCallback(async (): Promise<RowboatAccountSnapshot | null> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('account:getRowboat', null);
|
||||
const next: RowboatAccountSnapshot = {
|
||||
signedIn: result.signedIn,
|
||||
accessToken: result.accessToken,
|
||||
config: result.config,
|
||||
};
|
||||
setState(next);
|
||||
return next;
|
||||
} catch (error) {
|
||||
console.error('Failed to load Rowboat account state:', error);
|
||||
setState(DEFAULT_STATE);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider !== 'rowboat') {
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
return cleanup;
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
signedIn: state.signedIn,
|
||||
accessToken: state.accessToken,
|
||||
config: state.config,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
48
apps/x/apps/renderer/src/hooks/useSmoothedText.ts
Normal file
48
apps/x/apps/renderer/src/hooks/useSmoothedText.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Smoothly reveals streamed text by buffering incoming chunks and releasing
|
||||
* them gradually via requestAnimationFrame, producing the fluid typing effect
|
||||
* seen in apps like Claude and ChatGPT.
|
||||
*/
|
||||
export function useSmoothedText(targetText: string): string {
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
const targetRef = useRef('')
|
||||
const displayLenRef = useRef(0)
|
||||
const rafRef = useRef<number>(0)
|
||||
|
||||
targetRef.current = targetText
|
||||
|
||||
useEffect(() => {
|
||||
// Target cleared → immediately clear display
|
||||
if (!targetText) {
|
||||
displayLenRef.current = 0
|
||||
setDisplayText('')
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const target = targetRef.current
|
||||
if (!target) return
|
||||
|
||||
const currentLen = displayLenRef.current
|
||||
if (currentLen < target.length) {
|
||||
const remaining = target.length - currentLen
|
||||
// Adaptive speed: reveal faster when buffer is large, slower when small
|
||||
const step = Math.max(2, Math.ceil(remaining * 0.18))
|
||||
displayLenRef.current = Math.min(currentLen + step, target.length)
|
||||
setDisplayText(target.slice(0, displayLenRef.current))
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
// When caught up, stop. New useEffect call restarts when more text arrives.
|
||||
}
|
||||
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [targetText])
|
||||
|
||||
return displayText
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening';
|
||||
|
||||
|
|
@ -11,10 +15,16 @@ const DEEPGRAM_PARAMS = new URLSearchParams({
|
|||
smart_format: 'true',
|
||||
punctuate: 'true',
|
||||
language: 'en',
|
||||
endpointing: '100',
|
||||
no_delay: 'true',
|
||||
});
|
||||
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
|
||||
|
||||
// Cache auth details so we don't need IPC round-trips on every mic click
|
||||
let cachedAuth: { type: 'rowboat'; url: string; token: string } | { type: 'local'; apiKey: string } | null = null;
|
||||
|
||||
export function useVoiceMode() {
|
||||
const { refresh: refreshRowboatAccount } = useRowboatAccount();
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [interimText, setInterimText] = useState('');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
|
@ -23,28 +33,54 @@ export function useVoiceMode() {
|
|||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const transcriptBufferRef = useRef('');
|
||||
const interimRef = useRef('');
|
||||
// Buffer audio chunks captured before the WebSocket is ready
|
||||
const audioBufferRef = useRef<ArrayBuffer[]>([]);
|
||||
|
||||
// Connect (or reconnect) the Deepgram WebSocket.
|
||||
// Fetches a fresh token on each connect — temp tokens have short TTL.
|
||||
// Refresh cached auth details (called on warmup, not on mic click)
|
||||
const refreshAuth = useCallback(async () => {
|
||||
const account = await refreshRowboatAccount();
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
) {
|
||||
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
|
||||
} else {
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (config?.deepgram) {
|
||||
cachedAuth = { type: 'local', apiKey: config.deepgram.apiKey };
|
||||
}
|
||||
}
|
||||
}, [refreshRowboatAccount]);
|
||||
|
||||
// Create and connect a Deepgram WebSocket using cached auth.
|
||||
// Starts the connection and returns immediately (does not wait for open).
|
||||
const connectWs = useCallback(async () => {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
|
||||
|
||||
let ws: WebSocket;
|
||||
// Refresh auth if we don't have it cached yet
|
||||
if (!cachedAuth) {
|
||||
await refreshAuth();
|
||||
}
|
||||
if (!cachedAuth) return;
|
||||
|
||||
// Try signed-in proxy token first (passed as query param for JWTs)
|
||||
const result = await window.ipc.invoke('voice:getDeepgramToken', null);
|
||||
if (result) {
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['bearer', result.token]);
|
||||
let ws: WebSocket;
|
||||
if (cachedAuth.type === 'rowboat') {
|
||||
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, DEEPGRAM_PARAMS);
|
||||
ws = new WebSocket(listenUrl, ['bearer', cachedAuth.token]);
|
||||
} else {
|
||||
// Fall back to local API key (passed as subprotocol)
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (!config?.deepgram) return;
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', cachedAuth.apiKey]);
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[voice] WebSocket connected');
|
||||
// Flush any buffered audio captured while we were connecting
|
||||
const buffered = audioBufferRef.current;
|
||||
audioBufferRef.current = [];
|
||||
for (const chunk of buffered) {
|
||||
ws.send(chunk);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
|
@ -66,13 +102,15 @@ export function useVoiceMode() {
|
|||
|
||||
ws.onerror = () => {
|
||||
console.error('[voice] WebSocket error');
|
||||
// Auth may be stale — clear cache so next attempt refreshes
|
||||
cachedAuth = null;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[voice] WebSocket closed');
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshAuth]);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
|
|
@ -93,6 +131,7 @@ export function useVoiceMode() {
|
|||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
audioBufferRef.current = [];
|
||||
setInterimText('');
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
|
|
@ -105,60 +144,50 @@ export function useVoiceMode() {
|
|||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setInterimText('');
|
||||
audioBufferRef.current = [];
|
||||
|
||||
// If WS isn't connected, connect and wait for it
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
setState('connecting');
|
||||
connectWs();
|
||||
// Wait for WS to be ready (up to 5 seconds)
|
||||
const wsOk = await new Promise<boolean>((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
resolve(false);
|
||||
}, 5000);
|
||||
});
|
||||
if (!wsOk) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show listening immediately — don't wait for WebSocket
|
||||
setState('listening');
|
||||
analytics.voiceInputStarted();
|
||||
posthog.people.set_once({ has_used_voice: true });
|
||||
|
||||
// Start mic
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
console.error('Microphone access denied:', err);
|
||||
// Kick off mic + WebSocket in parallel, don't await WebSocket
|
||||
const [stream] = await Promise.all([
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
|
||||
console.error('Microphone access denied:', err);
|
||||
return null;
|
||||
}),
|
||||
connectWs(),
|
||||
]);
|
||||
|
||||
if (!stream) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// Start audio capture
|
||||
// Start audio capture immediately — buffer if WS isn't open yet
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
const processor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||||
processorRef.current = processor;
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
const float32 = e.inputBuffer.getChannelData(0);
|
||||
const int16 = new Int16Array(float32.length);
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
wsRef.current.send(int16.buffer);
|
||||
const buffer = int16.buffer;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(buffer);
|
||||
} else {
|
||||
// WebSocket still connecting — buffer the audio
|
||||
audioBufferRef.current.push(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
|
|
@ -181,5 +210,10 @@ export function useVoiceMode() {
|
|||
stopAudioCapture();
|
||||
}, [stopAudioCapture]);
|
||||
|
||||
return { state, interimText, start, submit, cancel };
|
||||
/** Pre-cache auth details so mic click skips IPC round-trips */
|
||||
const warmup = useCallback(() => {
|
||||
refreshAuth().catch(() => {});
|
||||
}, [refreshAuth]);
|
||||
|
||||
return { state, interimText, start, submit, cancel, warmup };
|
||||
}
|
||||
|
|
|
|||
37
apps/x/apps/renderer/src/lib/analytics.ts
Normal file
37
apps/x/apps/renderer/src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import posthog from 'posthog-js'
|
||||
|
||||
export function chatSessionCreated(runId: string) {
|
||||
posthog.capture('chat_session_created', { run_id: runId })
|
||||
}
|
||||
|
||||
export function chatMessageSent(props: {
|
||||
voiceInput?: boolean
|
||||
voiceOutput?: string
|
||||
searchEnabled?: boolean
|
||||
}) {
|
||||
posthog.capture('chat_message_sent', {
|
||||
voice_input: props.voiceInput ?? false,
|
||||
voice_output: props.voiceOutput ?? false,
|
||||
search_enabled: props.searchEnabled ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
export function oauthConnected(provider: string) {
|
||||
posthog.capture('oauth_connected', { provider })
|
||||
}
|
||||
|
||||
export function oauthDisconnected(provider: string) {
|
||||
posthog.capture('oauth_disconnected', { provider })
|
||||
}
|
||||
|
||||
export function voiceInputStarted() {
|
||||
posthog.capture('voice_input_started')
|
||||
}
|
||||
|
||||
export function searchExecuted(types: string[]) {
|
||||
posthog.capture('search_executed', { types })
|
||||
}
|
||||
|
||||
export function noteExported(format: string) {
|
||||
posthog.capture('note_exported', { format })
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ToolUIPart } from 'ai'
|
||||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||
|
||||
export interface MessageAttachment {
|
||||
path: string
|
||||
|
|
@ -46,6 +47,11 @@ export type ChatTabViewState = {
|
|||
permissionResponses: Map<string, PermissionResponse>
|
||||
}
|
||||
|
||||
export type ChatViewportAnchorState = {
|
||||
messageId: string | null
|
||||
requestKey: number
|
||||
}
|
||||
|
||||
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||
runId: null,
|
||||
conversation: [],
|
||||
|
|
@ -115,35 +121,27 @@ export type WebSearchCardData = {
|
|||
|
||||
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
|
||||
if (tool.name === 'web-search') {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
return {
|
||||
query: (input?.query as string) || '',
|
||||
results: (result?.results as WebSearchCardResult[]) || [],
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.name === 'research-search') {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
const rawResults = (result?.results as Array<{
|
||||
title: string
|
||||
url: string
|
||||
description?: string
|
||||
highlights?: string[]
|
||||
text?: string
|
||||
}>) || []
|
||||
const mapped = rawResults.map((entry) => ({
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
|
||||
description: entry.description || entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
|
||||
}))
|
||||
const category = input?.category as string | undefined
|
||||
return {
|
||||
query: (input?.query as string) || '',
|
||||
results: mapped,
|
||||
title: category
|
||||
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
|
||||
: 'Researched the web',
|
||||
title: (!category || category === 'general')
|
||||
? 'Web search'
|
||||
: `${category.charAt(0).toUpperCase() + category.slice(1)} search`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +231,194 @@ export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null =
|
|||
}
|
||||
}
|
||||
|
||||
const BROWSER_PENDING_LABELS: Record<string, string> = {
|
||||
open: 'Opening browser...',
|
||||
'get-state': 'Reading browser state...',
|
||||
'new-tab': 'Opening new browser tab...',
|
||||
'switch-tab': 'Switching browser tab...',
|
||||
'close-tab': 'Closing browser tab...',
|
||||
navigate: 'Navigating browser...',
|
||||
back: 'Going back...',
|
||||
forward: 'Going forward...',
|
||||
reload: 'Reloading page...',
|
||||
'read-page': 'Reading page...',
|
||||
click: 'Clicking page element...',
|
||||
type: 'Typing into page...',
|
||||
press: 'Sending key press...',
|
||||
scroll: 'Scrolling page...',
|
||||
wait: 'Waiting for page...',
|
||||
}
|
||||
|
||||
const truncateLabel = (value: string, max = 72): string => {
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= max) return normalized
|
||||
return `${normalized.slice(0, Math.max(0, max - 3)).trim()}...`
|
||||
}
|
||||
|
||||
const safeBrowserString = (value: unknown): string | null => {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const parseBrowserUrl = (value: string | null): URL | null => {
|
||||
if (!value) return null
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getGoogleSearchQuery = (value: string | null): string | null => {
|
||||
const parsed = parseBrowserUrl(value)
|
||||
if (!parsed) return null
|
||||
const hostname = parsed.hostname.replace(/^www\./, '')
|
||||
if (hostname !== 'google.com' && !hostname.endsWith('.google.com')) return null
|
||||
if (parsed.pathname !== '/search') return null
|
||||
const query = parsed.searchParams.get('q')?.trim()
|
||||
return query ? truncateLabel(query, 56) : null
|
||||
}
|
||||
|
||||
const formatBrowserTarget = (value: string | null): string | null => {
|
||||
const parsed = parseBrowserUrl(value)
|
||||
if (!parsed) {
|
||||
return value ? truncateLabel(value, 56) : null
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.replace(/^www\./, '')
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname
|
||||
const suffix = parsed.search ? `${path}${parsed.search}` : path
|
||||
return truncateLabel(`${hostname}${suffix}`, 56)
|
||||
}
|
||||
|
||||
const sanitizeBrowserDescription = (value: string | null): string | null => {
|
||||
if (!value) return null
|
||||
|
||||
let text = value
|
||||
.replace(/^(clicked|typed into|pressed)\s+/i, '')
|
||||
.replace(/\.$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (!text) return null
|
||||
|
||||
const looksLikeCssNoise =
|
||||
/(^|[\s"])(body|html)\b/i.test(text)
|
||||
|| /display:|position:|background-color|align-items|justify-content|z-index|var\(--|left:|top:/i.test(text)
|
||||
|| /\.[A-Za-z0-9_-]+\{/.test(text)
|
||||
|
||||
if (looksLikeCssNoise || text.length > 88) {
|
||||
const quoted = Array.from(text.matchAll(/"([^"]+)"/g))
|
||||
.map((match) => match[1]?.trim())
|
||||
.find((candidate) => candidate && !/display:|position:|background-color|var\(--/i.test(candidate))
|
||||
|
||||
if (!quoted) return null
|
||||
text = `"${truncateLabel(quoted, 44)}"`
|
||||
}
|
||||
|
||||
if (/^(body|html)\b/i.test(text)) return null
|
||||
return truncateLabel(text, 64)
|
||||
}
|
||||
|
||||
const getBrowserSuccessLabel = (
|
||||
action: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
result: Record<string, unknown> | undefined,
|
||||
): string | null => {
|
||||
const page = result?.page as Record<string, unknown> | undefined
|
||||
const pageUrl = safeBrowserString(page?.url)
|
||||
const resultMessage = safeBrowserString(result?.message)
|
||||
|
||||
switch (action) {
|
||||
case 'open':
|
||||
return 'Opened browser'
|
||||
case 'get-state':
|
||||
return 'Read browser state'
|
||||
case 'new-tab': {
|
||||
const query = getGoogleSearchQuery(pageUrl)
|
||||
if (query) return `Opened search for "${query}"`
|
||||
const target = formatBrowserTarget(pageUrl) || safeBrowserString(input?.target)
|
||||
return target ? `Opened ${target}` : 'Opened new tab'
|
||||
}
|
||||
case 'switch-tab':
|
||||
return 'Switched browser tab'
|
||||
case 'close-tab':
|
||||
return 'Closed browser tab'
|
||||
case 'navigate': {
|
||||
const query = getGoogleSearchQuery(pageUrl)
|
||||
if (query) return `Searched Google for "${query}"`
|
||||
const target = formatBrowserTarget(pageUrl) || formatBrowserTarget(safeBrowserString(input?.target))
|
||||
return target ? `Opened ${target}` : 'Navigated browser'
|
||||
}
|
||||
case 'back':
|
||||
return 'Went back'
|
||||
case 'forward':
|
||||
return 'Went forward'
|
||||
case 'reload':
|
||||
return 'Reloaded page'
|
||||
case 'read-page': {
|
||||
const title = safeBrowserString(page?.title)
|
||||
return title ? `Read ${truncateLabel(title, 52)}` : 'Read page'
|
||||
}
|
||||
case 'click': {
|
||||
const detail = sanitizeBrowserDescription(resultMessage)
|
||||
if (detail) return `Clicked ${detail}`
|
||||
if (typeof input?.index === 'number') return `Clicked element ${input.index}`
|
||||
return 'Clicked page element'
|
||||
}
|
||||
case 'type': {
|
||||
const detail = sanitizeBrowserDescription(resultMessage)
|
||||
if (detail) return `Typed into ${detail}`
|
||||
if (typeof input?.index === 'number') return `Typed into element ${input.index}`
|
||||
return 'Typed into page'
|
||||
}
|
||||
case 'press': {
|
||||
const key = safeBrowserString(input?.key)
|
||||
return key ? `Pressed ${truncateLabel(key, 20)}` : 'Sent key press'
|
||||
}
|
||||
case 'scroll':
|
||||
return `Scrolled ${input?.direction === 'up' ? 'up' : 'down'}`
|
||||
case 'wait': {
|
||||
const ms = typeof input?.ms === 'number' ? input.ms : 1000
|
||||
return `Waited ${ms}ms`
|
||||
}
|
||||
default:
|
||||
return resultMessage ? truncateLabel(resultMessage, 72) : 'Controlled browser'
|
||||
}
|
||||
}
|
||||
|
||||
export const getBrowserControlLabel = (tool: ToolCall): string | null => {
|
||||
if (tool.name !== 'browser-control') return null
|
||||
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
const action = (input?.action as string | undefined) || (result?.action as string | undefined) || 'browser'
|
||||
|
||||
if (tool.status !== 'completed') {
|
||||
if (action === 'click' && typeof input?.index === 'number') {
|
||||
return `Clicking element ${input.index}...`
|
||||
}
|
||||
if (action === 'type' && typeof input?.index === 'number') {
|
||||
return `Typing into element ${input.index}...`
|
||||
}
|
||||
if (action === 'navigate' && typeof input?.target === 'string') {
|
||||
return `Navigating to ${input.target}...`
|
||||
}
|
||||
return BROWSER_PENDING_LABELS[action] || 'Controlling browser...'
|
||||
}
|
||||
|
||||
if (result?.success === false) {
|
||||
const error = safeBrowserString(result.error)
|
||||
return error ? `Browser error: ${truncateLabel(error, 84)}` : 'Browser action failed'
|
||||
}
|
||||
|
||||
const label = getBrowserSuccessLabel(action, input, result)
|
||||
if (label) {
|
||||
return label
|
||||
}
|
||||
|
||||
return 'Controlled browser'
|
||||
}
|
||||
|
||||
// Parse attached files from message content and return clean message + file paths.
|
||||
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||
|
|
@ -261,6 +447,145 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
|
|||
return { message: cleanMessage.trim(), files }
|
||||
}
|
||||
|
||||
// Composio connect card data
|
||||
export type ComposioConnectCardData = {
|
||||
toolkitSlug: string
|
||||
toolkitDisplayName: string
|
||||
alreadyConnected: boolean
|
||||
/** When true, the connect card should not be rendered (toolkit was already connected). */
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
|
||||
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
|
||||
if (tool.name !== 'composio-connect-toolkit') return null
|
||||
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
const toolkitSlug = (input?.toolkitSlug as string) || ''
|
||||
const alreadyConnected = result?.alreadyConnected === true
|
||||
|
||||
return {
|
||||
toolkitSlug,
|
||||
toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug,
|
||||
alreadyConnected,
|
||||
// Don't render a connect card if the toolkit was already connected —
|
||||
// the original card from the first connect call already shows the "Connected" state.
|
||||
hidden: alreadyConnected,
|
||||
}
|
||||
}
|
||||
|
||||
// Human-friendly display names for builtin tools
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'workspace-readFile': 'Reading file',
|
||||
'workspace-writeFile': 'Writing file',
|
||||
'workspace-edit': 'Editing file',
|
||||
'workspace-readdir': 'Reading directory',
|
||||
'workspace-exists': 'Checking path',
|
||||
'workspace-stat': 'Getting file info',
|
||||
'workspace-glob': 'Finding files',
|
||||
'workspace-grep': 'Searching files',
|
||||
'workspace-mkdir': 'Creating directory',
|
||||
'workspace-rename': 'Renaming',
|
||||
'workspace-copy': 'Copying file',
|
||||
'workspace-remove': 'Removing',
|
||||
'workspace-getRoot': 'Getting workspace root',
|
||||
'loadSkill': 'Loading skill',
|
||||
'parseFile': 'Parsing file',
|
||||
'LLMParse': 'Extracting content',
|
||||
'analyzeAgent': 'Analyzing agent',
|
||||
'executeCommand': 'Running command',
|
||||
'addMcpServer': 'Adding MCP server',
|
||||
'listMcpServers': 'Listing MCP servers',
|
||||
'listMcpTools': 'Listing MCP tools',
|
||||
'executeMcpTool': 'Running MCP tool',
|
||||
'web-search': 'Searching the web',
|
||||
'save-to-memory': 'Saving to memory',
|
||||
'app-navigation': 'Navigating app',
|
||||
'browser-control': 'Controlling browser',
|
||||
'composio-list-toolkits': 'Listing integrations',
|
||||
'composio-search-tools': 'Searching tools',
|
||||
'composio-execute-tool': 'Running tool',
|
||||
'composio-connect-toolkit': 'Connecting service',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-friendly display name for a tool call.
|
||||
* For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail").
|
||||
* For builtin tools, returns a static friendly name (e.g., "Reading file").
|
||||
* Falls back to the raw tool name if no mapping exists.
|
||||
*/
|
||||
export const getToolDisplayName = (tool: ToolCall): string => {
|
||||
const browserLabel = getBrowserControlLabel(tool)
|
||||
if (browserLabel) return browserLabel
|
||||
const composioData = getComposioActionCardData(tool)
|
||||
if (composioData) return composioData.label
|
||||
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
|
||||
}
|
||||
|
||||
// Composio action card data (for search, execute, list tools)
|
||||
export type ComposioActionCardData = {
|
||||
actionType: 'search' | 'execute' | 'list'
|
||||
label: string
|
||||
}
|
||||
|
||||
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
if (tool.name === 'composio-search-tools') {
|
||||
const query = (input?.query as string) || 'tools'
|
||||
const toolkitSlug = input?.toolkitSlug as string | undefined
|
||||
const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null
|
||||
const count = (result?.resultCount as number) ?? null
|
||||
|
||||
let label = `Searching for "${query}"`
|
||||
if (toolkit) label += ` in ${toolkit}`
|
||||
if (count !== null && tool.status === 'completed') {
|
||||
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
|
||||
if (toolkit) label += ` in ${toolkit}`
|
||||
}
|
||||
return { actionType: 'search', label }
|
||||
}
|
||||
|
||||
if (tool.name === 'composio-execute-tool') {
|
||||
const toolSlug = (input?.toolSlug as string) || ''
|
||||
const toolkitSlug = (input?.toolkitSlug as string) || ''
|
||||
const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug
|
||||
const successful = result?.successful as boolean | undefined
|
||||
|
||||
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
|
||||
const readableName = toolSlug
|
||||
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^\w/, c => c.toUpperCase())
|
||||
|
||||
let label = `Running ${readableName}`
|
||||
if (toolkit) label += ` on ${toolkit}`
|
||||
if (tool.status === 'completed') {
|
||||
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
|
||||
if (toolkit) label += ` on ${toolkit}`
|
||||
}
|
||||
return { actionType: 'execute', label }
|
||||
}
|
||||
|
||||
if (tool.name === 'composio-list-toolkits') {
|
||||
const count = (result?.totalCount as number) ?? null
|
||||
const connected = (result?.connectedCount as number) ?? null
|
||||
|
||||
let label = 'Listing available integrations'
|
||||
if (count !== null && tool.status === 'completed') {
|
||||
label = `${count} integrations available`
|
||||
if (connected !== null && connected > 0) label += `, ${connected} connected`
|
||||
}
|
||||
return { actionType: 'list', label }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
10
apps/x/apps/renderer/src/lib/deepgram-listen-url.ts
Normal file
10
apps/x/apps/renderer/src/lib/deepgram-listen-url.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Merge Deepgram query params onto a Rowboat WebSocket base URL from account config.
|
||||
*/
|
||||
export function buildDeepgramListenUrl(baseWsUrl: string, params: URLSearchParams): string {
|
||||
const url = new URL("/deepgram/v1/listen", baseWsUrl);
|
||||
for (const [key, value] of params) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
let googleClientId: string | null = null;
|
||||
|
||||
export function getGoogleClientId(): string | null {
|
||||
return googleClientId;
|
||||
}
|
||||
|
||||
export function setGoogleClientId(clientId: string): void {
|
||||
const trimmed = clientId.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
googleClientId = trimmed;
|
||||
}
|
||||
|
||||
export function clearGoogleClientId(): void {
|
||||
googleClientId = null;
|
||||
}
|
||||
23
apps/x/apps/renderer/src/lib/google-credentials-store.ts
Normal file
23
apps/x/apps/renderer/src/lib/google-credentials-store.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface GoogleCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
let credentials: GoogleCredentials | null = null;
|
||||
|
||||
export function getGoogleCredentials(): GoogleCredentials | null {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
export function setGoogleCredentials(clientId: string, clientSecret: string): void {
|
||||
const trimmedId = clientId.trim();
|
||||
const trimmedSecret = clientSecret.trim();
|
||||
if (!trimmedId || !trimmedSecret) {
|
||||
return;
|
||||
}
|
||||
credentials = { clientId: trimmedId, clientSecret: trimmedSecret };
|
||||
}
|
||||
|
||||
export function clearGoogleCredentials(): void {
|
||||
credentials = null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/* =============================================================
|
||||
Track Modal — dialog overlay for track block details / edits
|
||||
============================================================= */
|
||||
|
||||
.track-modal-content {
|
||||
--track-accent: #64748b;
|
||||
}
|
||||
|
||||
.track-modal-content[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-modal-content[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-modal-content[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-modal-content[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
/* Header */
|
||||
.track-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--track-accent) 6%, transparent);
|
||||
border-left: 4px solid var(--track-accent);
|
||||
}
|
||||
|
||||
.track-modal-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-modal-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--track-accent) 15%, transparent);
|
||||
color: var(--track-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-modal-title-col {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.track-modal-subtitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.track-modal-subtitle-sep {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.track-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-modal-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-modal-toggle-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.track-modal-tabs {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.track-modal-tab {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease, border-color 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-modal-tab:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-tab-active {
|
||||
color: var(--track-accent);
|
||||
border-bottom-color: var(--track-accent);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.track-modal-body {
|
||||
padding: 18px 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track-modal-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.track-modal-prose {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-markdown {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.track-modal-markdown > *:first-child { margin-top: 0; }
|
||||
.track-modal-markdown > *:last-child { margin-bottom: 0; }
|
||||
|
||||
.track-modal-empty {
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* When-to-run panel */
|
||||
.track-modal-when {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.track-modal-when-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--track-accent);
|
||||
padding: 12px 14px;
|
||||
background: color-mix(in srgb, var(--track-accent) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--track-accent);
|
||||
}
|
||||
|
||||
/* Description list (Details / When) */
|
||||
.track-modal-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.track-modal-dl dt {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-modal-dl dd {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.track-modal-dl code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
padding: 1px 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Advanced / raw YAML disclosure */
|
||||
.track-modal-advanced {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-advanced-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-modal-advanced-toggle:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-raw-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.track-modal-textarea {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.track-modal-raw-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.track-modal-danger-zone {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--destructive, #ef4444) 20%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-delete-btn {
|
||||
color: color-mix(in srgb, var(--destructive, #ef4444) 85%, var(--foreground));
|
||||
border-color: color-mix(in srgb, var(--destructive, #ef4444) 30%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-delete-btn:hover {
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.track-modal-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.track-modal-confirm-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.track-modal-error {
|
||||
margin: 0 20px 14px 20px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.track-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-run-btn {
|
||||
background: var(--track-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.track-modal-run-btn:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 85%, black);
|
||||
}
|
||||
|
|
@ -24,7 +24,9 @@
|
|||
"ai": "^5.0.133",
|
||||
"awilix": "^12.0.5",
|
||||
"chokidar": "^4.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"cron-parser": "^5.5.0",
|
||||
"express": "^5.2.1",
|
||||
"glob": "^13.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
|
|
@ -41,6 +43,8 @@
|
|||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { glob } from "node:fs/promises";
|
|||
import path from "path";
|
||||
import z from "zod";
|
||||
import { Agent } from "@x/shared/dist/agent.js";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { stringify } from "yaml";
|
||||
import { parseFrontmatter } from "../application/lib/parse-frontmatter.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const UpdateAgentSchema = Agent.omit({ name: true });
|
||||
|
|
@ -33,7 +34,10 @@ export class FSAgentsRepo implements IAgentsRepo {
|
|||
for (const file of matches) {
|
||||
try {
|
||||
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
|
||||
result.push(agent);
|
||||
result.push({
|
||||
...agent,
|
||||
name: file.replace(/\.md$/, ""),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
continue;
|
||||
|
|
@ -42,44 +46,33 @@ export class FSAgentsRepo implements IAgentsRepo {
|
|||
return result;
|
||||
}
|
||||
|
||||
private async parseAgentMd(filePath: string): Promise<z.infer<typeof Agent>> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
private async parseAgentMd(filepath: string): Promise<z.infer<typeof Agent>> {
|
||||
const raw = await fs.readFile(filepath, "utf8");
|
||||
|
||||
// strip the path prefix from the file name
|
||||
// and the .md extension
|
||||
const agentName = filePath
|
||||
.replace(this.agentsDir + "/", "")
|
||||
.replace(/\.md$/, "");
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: agentName,
|
||||
instructions: raw,
|
||||
};
|
||||
let content = raw;
|
||||
const { frontmatter, content } = parseFrontmatter(raw);
|
||||
if (frontmatter) {
|
||||
const parsed = Agent
|
||||
.omit({ instructions: true })
|
||||
.parse(frontmatter);
|
||||
|
||||
// check for frontmatter markers at start
|
||||
if (raw.startsWith("---")) {
|
||||
const end = raw.indexOf("\n---", 3);
|
||||
|
||||
if (end !== -1) {
|
||||
const fm = raw.slice(3, end).trim(); // YAML text
|
||||
content = raw.slice(end + 4).trim(); // body after frontmatter
|
||||
const yaml = parse(fm);
|
||||
const parsed = Agent
|
||||
.omit({ name: true, instructions: true })
|
||||
.parse(yaml);
|
||||
agent = {
|
||||
...agent,
|
||||
...parsed,
|
||||
instructions: content,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
instructions: content,
|
||||
};
|
||||
}
|
||||
|
||||
return agent;
|
||||
return {
|
||||
name: filepath,
|
||||
instructions: raw,
|
||||
};
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof Agent>> {
|
||||
return this.parseAgentMd(path.join(this.agentsDir, `${id}.md`));
|
||||
const agent = await this.parseAgentMd(path.join(this.agentsDir, `${id}.md`));
|
||||
return {
|
||||
...agent,
|
||||
name: id,
|
||||
};
|
||||
}
|
||||
|
||||
async create(agent: z.infer<typeof Agent>): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,10 @@ import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
|||
import { execTool } from "../application/lib/exec-tool.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||
import { SKILL_CATALOG_PLACEHOLDER } from "../application/assistant/instructions.js";
|
||||
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||
import { buildTrackRunAgent } from "../knowledge/track/run-agent.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { ISkillResolver } from "../skills/resolver.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
|
|
@ -32,6 +31,61 @@ import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
|||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
|
||||
function loadAgentNotesContext(): string | null {
|
||||
const sections: string[] = [];
|
||||
|
||||
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
|
||||
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(userFile)) {
|
||||
const content = fs.readFileSync(userFile, 'utf-8').trim();
|
||||
if (content) {
|
||||
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
if (fs.existsSync(prefsFile)) {
|
||||
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
|
||||
if (content) {
|
||||
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// List other Agent Notes files for on-demand access
|
||||
const otherFiles: string[] = [];
|
||||
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
|
||||
try {
|
||||
if (fs.existsSync(AGENT_NOTES_DIR)) {
|
||||
function listMdFiles(dir: string, prefix: string) {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
listMdFiles(fullPath, `${prefix}${entry}/`);
|
||||
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
|
||||
otherFiles.push(`${prefix}${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
listMdFiles(AGENT_NOTES_DIR, '');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (otherFiles.length > 0) {
|
||||
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using workspace-readFile. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
||||
}
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
|
|
@ -316,12 +370,11 @@ function formatLlmStreamError(rawError: unknown): string {
|
|||
|
||||
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||
if (id === "copilot" || id === "rowboatx") {
|
||||
const resolver = container.resolve<ISkillResolver>("skillResolver");
|
||||
const catalogMarkdown = await resolver.generateCatalogMarkdown();
|
||||
return {
|
||||
...CopilotAgent,
|
||||
instructions: CopilotAgent.instructions.replace(SKILL_CATALOG_PLACEHOLDER, catalogMarkdown),
|
||||
};
|
||||
return buildCopilotAgent();
|
||||
}
|
||||
|
||||
if (id === "track-run") {
|
||||
return buildTrackRunAgent();
|
||||
}
|
||||
|
||||
if (id === 'note_creation') {
|
||||
|
|
@ -425,6 +478,31 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return agent;
|
||||
}
|
||||
|
||||
if (id === 'agent_notes_agent') {
|
||||
const agentNotesAgentRaw = getAgentNotesAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: agentNotesAgentRaw,
|
||||
};
|
||||
|
||||
if (agentNotesAgentRaw.startsWith("---")) {
|
||||
const end = agentNotesAgentRaw.indexOf("\n---", 3);
|
||||
if (end !== -1) {
|
||||
const fm = agentNotesAgentRaw.slice(3, end).trim();
|
||||
const content = agentNotesAgentRaw.slice(end + 4).trim();
|
||||
const yaml = parse(fm);
|
||||
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
|
||||
agent = {
|
||||
...agent,
|
||||
...parsed,
|
||||
instructions: content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
|
@ -493,7 +571,8 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
for (const part of msg.content) {
|
||||
if (part.type === "attachment") {
|
||||
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
|
||||
const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
|
||||
} else {
|
||||
textSegments.push(part.text);
|
||||
}
|
||||
|
|
@ -777,17 +856,32 @@ export async function* streamAgent({
|
|||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const provider = await isSignedIn()
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(modelConfig.provider);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"];
|
||||
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: modelConfig.model;
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
|
||||
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
|
||||
const isInlineTaskAgent = state.agentName === "inline_task_agent";
|
||||
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
|
||||
const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel;
|
||||
const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel;
|
||||
const modelId = isInlineTaskAgent
|
||||
? defaultInlineTaskModel
|
||||
: (isKgAgent && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: isKgAgent ? defaultKgModel : defaultModel;
|
||||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId}`);
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
let middlePaneContext:
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string }
|
||||
| null = null;
|
||||
while (true) {
|
||||
// Check abort at the top of each iteration
|
||||
signal.throwIfAborted();
|
||||
|
|
@ -901,9 +995,6 @@ export async function* streamAgent({
|
|||
}
|
||||
|
||||
// get any queued user messages
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
while (true) {
|
||||
const msg = await messageQueue.dequeue(runId);
|
||||
if (!msg) {
|
||||
|
|
@ -918,6 +1009,9 @@ export async function* streamAgent({
|
|||
if (msg.voiceOutput) {
|
||||
voiceOutput = msg.voiceOutput;
|
||||
}
|
||||
// Middle pane is NOT sticky — it should reflect the state at the moment of the
|
||||
// latest user message. If the user closed the pane between messages, clear it.
|
||||
middlePaneContext = msg.middlePaneContext ?? null;
|
||||
loopLogger.log('dequeued user message', msg.messageId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
|
|
@ -958,20 +1052,40 @@ export async function* streamAgent({
|
|||
timeZoneName: 'short'
|
||||
});
|
||||
let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
||||
// Inject Agent Notes context for copilot
|
||||
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
|
||||
const agentNotesContext = loadAgentNotesContext();
|
||||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
|
||||
// that supersedes any earlier middle-pane mention in the conversation history.
|
||||
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
|
||||
if (!middlePaneContext) {
|
||||
loopLogger.log('injecting middle pane context (empty)');
|
||||
instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`;
|
||||
} else if (middlePaneContext.kind === 'note') {
|
||||
loopLogger.log('injecting middle pane context (note)', middlePaneContext.path);
|
||||
instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``;
|
||||
} else if (middlePaneContext.kind === 'browser') {
|
||||
loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url);
|
||||
instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`;
|
||||
}
|
||||
}
|
||||
if (voiceInput) {
|
||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
|
||||
}
|
||||
if (voiceOutput === 'summary') {
|
||||
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY)\nThe user has voice output enabled. You MUST start your response with <voice></voice> tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with <voice> tags.\n\nRules:\n1. ALWAYS start your response with one or more <voice> tags. Never skip them.\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n\nExample — if the user asks "what happened in my meeting with Sarah yesterday?":\n<voice>Your meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more <voice> tags.)\n\nAny text outside <voice> tags is shown visually but not spoken.`;
|
||||
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
|
||||
} else if (voiceOutput === 'full') {
|
||||
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in <voice></voice> tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a <voice> tag. Do not leave any content outside <voice> tags.\n\nExample:\n<voice>Your meeting with Sarah covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>`;
|
||||
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
|
||||
}
|
||||
if (searchEnabled) {
|
||||
loopLogger.log('search enabled, injecting search prompt');
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`;
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
|
||||
}
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
|
|
|
|||
40
apps/x/packages/core/src/agents/utils.ts
Normal file
40
apps/x/packages/core/src/agents/utils.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { bus } from "../runs/bus.js";
|
||||
import { fetchRun } from "../runs/runs.js";
|
||||
|
||||
/**
|
||||
* Extract the assistant's final text response from a run's log.
|
||||
* @param runId
|
||||
* @returns The assistant's final text response or null if not found.
|
||||
*/
|
||||
export async function extractAgentResponse(runId: string): Promise<string | null> {
|
||||
const run = await fetchRun(runId);
|
||||
for (let i = run.log.length - 1; i >= 0; i--) {
|
||||
const event = run.log[i];
|
||||
if (event.type === 'message' && event.message.role === 'assistant') {
|
||||
const content = event.message.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => 'text' in p ? p.text : '')
|
||||
.join('');
|
||||
return text || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
export async function waitForRunCompletion(runId: string): Promise<void> {
|
||||
return new Promise(async (resolve) => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import z from "zod";
|
||||
import { CopilotInstructions } from "./instructions.js";
|
||||
import { buildCopilotInstructions } from "./instructions.js";
|
||||
import { BuiltinTools } from "../lib/builtin-tools.js";
|
||||
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
tools[name] = {
|
||||
type: "builtin",
|
||||
name,
|
||||
/**
|
||||
* Build the CopilotAgent dynamically.
|
||||
* Tools are derived from the current BuiltinTools (which include Composio meta-tools),
|
||||
* and instructions include the live Composio connection status.
|
||||
*/
|
||||
export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
tools[name] = { type: "builtin", name };
|
||||
}
|
||||
const instructions = await buildCopilotInstructions();
|
||||
return {
|
||||
name: "rowboatx",
|
||||
description: "Rowboatx copilot",
|
||||
instructions,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
export const CopilotAgent: z.infer<typeof Agent> = {
|
||||
name: "rowboatx",
|
||||
description: "Rowboatx copilot",
|
||||
instructions: CopilotInstructions,
|
||||
tools,
|
||||
}
|
||||
|
|
@ -1,11 +1,75 @@
|
|||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||
|
||||
export const SKILL_CATALOG_PLACEHOLDER = "{{SKILL_CATALOG}}";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
||||
import container from "../../di/container.js";
|
||||
import type { ISkillResolver } from "../../skills/resolver.js";
|
||||
import type { ResolvedSkill } from "@x/shared/dist/skill.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||
function buildSkillCatalogMarkdown(skills: ResolvedSkill[]): string {
|
||||
const sections = skills.map((skill) => [
|
||||
`## ${skill.title}`,
|
||||
`- **Skill file:** \`${skill.id}\``,
|
||||
`- **Use it for:** ${skill.summary}`,
|
||||
].join("\n"));
|
||||
|
||||
return [
|
||||
"# Rowboat Skill Catalog",
|
||||
"",
|
||||
"Use this catalog to see which specialized skills you can load. Each entry lists the skill id plus a short description of when it helps.",
|
||||
"",
|
||||
sections.join("\n\n"),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dynamic instructions section for Composio integrations.
|
||||
* Lists connected toolkits and explains the meta-tool discovery flow.
|
||||
*/
|
||||
async function getComposioToolsPrompt(): Promise<string> {
|
||||
if (!(await isComposioConfigured())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
|
||||
const connectedSection = connectedToolkits.length > 0
|
||||
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
|
||||
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
|
||||
|
||||
return `
|
||||
## Composio Integrations
|
||||
|
||||
${connectedSection}
|
||||
|
||||
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
|
||||
`;
|
||||
}
|
||||
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
|
||||
// Conditionally include Composio-related instruction sections
|
||||
const emailDraftSuffix = composioEnabled
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
|
||||
|
||||
const thirdPartyBlock = composioEnabled
|
||||
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
|
||||
: '';
|
||||
|
||||
const toolPriority = composioEnabled
|
||||
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
|
||||
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
|
||||
|
||||
const slackToolsLine = composioEnabled
|
||||
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
|
||||
: '';
|
||||
|
||||
const composioToolsLine = composioEnabled
|
||||
? `- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.\n`
|
||||
: '';
|
||||
|
||||
return `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||
|
||||
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
|
||||
|
||||
|
|
@ -26,9 +90,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
|
|||
## What Rowboat Is
|
||||
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
|
||||
|
||||
**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses.
|
||||
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
|
||||
|
||||
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
|
||||
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
|
||||
|
||||
|
|
@ -36,7 +100,33 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always show message drafts to the user before sending.
|
||||
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
|
||||
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
|
||||
|
||||
|
||||
## Learning About the User (save-to-memory)
|
||||
|
||||
Use the \`save-to-memory\` tool to note things worth remembering about the user. This builds a persistent profile that helps you serve them better over time. Call it proactively — don't ask permission.
|
||||
|
||||
**When to save:**
|
||||
- User states a preference: "I prefer bullet points"
|
||||
- User corrects your style: "too formal, keep it casual"
|
||||
- You learn about their relationships: "Monica is my co-founder"
|
||||
- You notice workflow patterns: "no meetings before 11am"
|
||||
- User gives explicit instructions: "never use em-dashes"
|
||||
- User has preferences for specific tasks: "pitch decks should be minimal, max 12 slides"
|
||||
|
||||
**Capture context, not blanket rules:**
|
||||
- BAD: "User prefers casual tone" — this loses important context
|
||||
- GOOD: "User prefers casual tone with internal team (Ramnique, Monica) but formal/polished with investors (Brad, Dalton)"
|
||||
- BAD: "User likes short emails" — too vague
|
||||
- GOOD: "User sends very terse 1-2 line emails to co-founder Ramnique, but writes structured 2-3 paragraph emails to investors with proper greetings"
|
||||
- Always note WHO or WHAT CONTEXT a preference applies to. Most preferences are situational, not universal.
|
||||
|
||||
**When NOT to save:**
|
||||
- Ephemeral task details ("draft an email about X")
|
||||
- Things already in the knowledge graph
|
||||
- Information you can derive from reading their notes
|
||||
|
||||
## Memory That Compounds
|
||||
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
|
||||
|
|
@ -44,7 +134,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
|
|||
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
|
||||
|
||||
## The Knowledge Graph
|
||||
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories:
|
||||
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
|
||||
- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
|
||||
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
|
||||
- **Organizations/** - Notes on companies and teams
|
||||
- **Projects/** - Notes on ongoing initiatives and workstreams
|
||||
|
|
@ -55,10 +146,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs
|
|||
## How to Access the Knowledge Graph
|
||||
|
||||
**CRITICAL PATH REQUIREMENT:**
|
||||
- The workspace root is \`~/.rowboat/\`
|
||||
- The workspace root is the configured workdir
|
||||
- The knowledge base is in the \`knowledge/\` subfolder
|
||||
- When using workspace tools, ALWAYS include \`knowledge/\` in the path
|
||||
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\`
|
||||
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root
|
||||
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
|
||||
|
||||
Use the builtin workspace tools to search and read the knowledge base:
|
||||
|
|
@ -118,7 +209,7 @@ Use the catalog below to decide which skills to load for each user request. Befo
|
|||
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
|
||||
- Apply the instructions from every loaded skill while working on the request.
|
||||
|
||||
${SKILL_CATALOG_PLACEHOLDER}
|
||||
${catalog}
|
||||
|
||||
Always consult this catalog first so you load the right skills before taking action.
|
||||
|
||||
|
|
@ -143,13 +234,9 @@ Always consult this catalog first so you load the right skills before taking act
|
|||
- Never start a response with a heading. Lead with a sentence or two of context first.
|
||||
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
|
||||
|
||||
## MCP Tool Discovery (CRITICAL)
|
||||
## Tool Priority
|
||||
|
||||
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
||||
|
||||
When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools.
|
||||
|
||||
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first!
|
||||
${toolPriority}
|
||||
|
||||
## Execution Reminders
|
||||
- Explore existing files and structure before creating new assets.
|
||||
|
|
@ -159,16 +246,16 @@ When a user asks for ANY task that might require external capabilities (web sear
|
|||
${runtimeContextPrompt}
|
||||
|
||||
## Workspace Access & Scope
|
||||
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||
|
||||
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
||||
**CRITICAL - When the user asks you to work with files outside the workspace root:**
|
||||
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
|
||||
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
||||
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||
- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
||||
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
||||
- NEVER ask what OS the user is on if runtime platform is already available.
|
||||
|
|
@ -185,18 +272,20 @@ ${runtimeContextPrompt}
|
|||
- \`analyzeAgent\` - Agent analysis
|
||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
|
||||
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
|
||||
${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
||||
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
|
||||
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
|
||||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||
${composioToolsLine}
|
||||
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`.
|
||||
|
||||
**Shell commands via \`executeCommand\`:**
|
||||
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately.
|
||||
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
|
||||
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
|
||||
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
|
||||
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
|
||||
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
|
||||
**CRITICAL: MCP Server Configuration**
|
||||
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
|
||||
|
|
@ -224,6 +313,37 @@ This renders as an interactive card in the UI that the user can click to open th
|
|||
- Files on the user's machine (~/Desktop/..., /Users/..., etc.)
|
||||
- Audio files, images, documents, or any file reference
|
||||
|
||||
Do NOT use filepath blocks for:
|
||||
- Website URLs or browser pages (\`https://...\`, \`http://...\`)
|
||||
- Anything currently open in the embedded browser
|
||||
- Browser tabs or browser tab ids
|
||||
|
||||
For browser pages, mention the URL in plain text or use the browser-control tool. Do not try to turn browser pages into clickable file cards.
|
||||
|
||||
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
|
||||
|
||||
Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`;
|
||||
}
|
||||
|
||||
let cachedInstructions: string | null = null;
|
||||
|
||||
export function invalidateCopilotInstructionsCache(): void {
|
||||
cachedInstructions = null;
|
||||
}
|
||||
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioEnabled = await isComposioConfigured();
|
||||
const resolver = container.resolve<ISkillResolver>("skillResolver");
|
||||
const allSkills = await resolver.getCatalog();
|
||||
const filteredSkills = composioEnabled
|
||||
? allSkills
|
||||
: allSkills.filter((s) => s.id !== 'composio-integration');
|
||||
const catalogMarkdown = buildSkillCatalogMarkdown(filteredSkills);
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalogMarkdown);
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
cachedInstructions = composioPrompt
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
: baseInstructions;
|
||||
return cachedInstructions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
export const skill = String.raw`
|
||||
# Browser Control Skill
|
||||
|
||||
You have access to the **browser-control** tool, which controls Rowboat's embedded browser pane directly.
|
||||
|
||||
Use this skill when the user asks you to open a website, browse in-app, search the web in the browser pane, click something on a page, fill a form, or otherwise interact with a live webpage inside Rowboat.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. Start with ` + "`browser-control({ action: \"open\" })`" + ` if the browser pane may not already be open.
|
||||
2. Use ` + "`browser-control({ action: \"read-page\" })`" + ` to inspect the current page.
|
||||
3. The tool returns:
|
||||
- ` + "`snapshotId`" + `
|
||||
- page ` + "`url`" + ` and ` + "`title`" + `
|
||||
- visible page text
|
||||
- interactable elements with numbered ` + "`index`" + ` values
|
||||
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
||||
5. After each action, read the returned page snapshot before deciding the next step.
|
||||
|
||||
## Actions
|
||||
|
||||
### open
|
||||
Open the browser pane and ensure an active tab exists.
|
||||
|
||||
### get-state
|
||||
Return the current browser tabs and active tab id.
|
||||
|
||||
### new-tab
|
||||
Open a new browser tab.
|
||||
|
||||
Parameters:
|
||||
- ` + "`target`" + ` (optional): URL or plain-language search query
|
||||
|
||||
### switch-tab
|
||||
Switch to a tab by ` + "`tabId`" + `.
|
||||
|
||||
### close-tab
|
||||
Close a tab by ` + "`tabId`" + `.
|
||||
|
||||
### navigate
|
||||
Navigate the active tab.
|
||||
|
||||
Parameters:
|
||||
- ` + "`target`" + `: URL or plain-language search query
|
||||
|
||||
Plain-language targets are converted into a search automatically.
|
||||
|
||||
### back / forward / reload
|
||||
Standard browser navigation controls.
|
||||
|
||||
### read-page
|
||||
Read the current page and return a compact snapshot.
|
||||
|
||||
Parameters:
|
||||
- ` + "`maxElements`" + ` (optional)
|
||||
- ` + "`maxTextLength`" + ` (optional)
|
||||
|
||||
### click
|
||||
Click an element.
|
||||
|
||||
Prefer:
|
||||
- ` + "`index`" + `: element index from ` + "`read-page`" + `
|
||||
|
||||
Optional:
|
||||
- ` + "`snapshotId`" + `: include it when acting on a recent snapshot
|
||||
- ` + "`selector`" + `: fallback only when no usable index exists
|
||||
|
||||
### type
|
||||
Type into an input, textarea, or contenteditable element.
|
||||
|
||||
Parameters:
|
||||
- ` + "`text`" + `: text to enter
|
||||
- plus the same target fields as ` + "`click`" + `
|
||||
|
||||
### press
|
||||
Send a key press such as ` + "`Enter`" + `, ` + "`Tab`" + `, ` + "`Escape`" + `, or arrow keys.
|
||||
|
||||
Parameters:
|
||||
- ` + "`key`" + `
|
||||
- optional target fields if you need to focus a specific element first
|
||||
|
||||
### scroll
|
||||
Scroll the current page.
|
||||
|
||||
Parameters:
|
||||
- ` + "`direction`" + `: ` + "`\"up\"`" + ` or ` + "`\"down\"`" + ` (optional; defaults down)
|
||||
- ` + "`amount`" + `: pixel distance (optional)
|
||||
|
||||
### wait
|
||||
Wait for the page to settle, useful after async UI changes.
|
||||
|
||||
Parameters:
|
||||
- ` + "`ms`" + `: milliseconds to wait (optional)
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Prefer ` + "`read-page`" + ` before interacting.
|
||||
- Prefer element ` + "`index`" + ` over CSS selectors.
|
||||
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
|
||||
- After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state.
|
||||
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
|
||||
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
|
||||
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
export const skill = String.raw`
|
||||
# Composio Integration
|
||||
|
||||
**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **composio-list-toolkits** | List all available integrations and their connection status |
|
||||
| **composio-search-tools** | Search for tools by use case; returns slugs and input schemas |
|
||||
| **composio-execute-tool** | Execute a tool by slug with parameters |
|
||||
| **composio-connect-toolkit** | Connect a service via OAuth (opens browser) |
|
||||
|
||||
## Toolkit Slugs (exact values for toolkitSlug parameter)
|
||||
|
||||
| Service | Slug |
|
||||
|---------|------|
|
||||
| Gmail | \`gmail\` |
|
||||
| Google Calendar | \`googlecalendar\` |
|
||||
| Google Sheets | \`googlesheets\` |
|
||||
| Google Docs | \`googledocs\` |
|
||||
| Google Drive | \`googledrive\` |
|
||||
| Slack | \`slack\` |
|
||||
| GitHub | \`github\` |
|
||||
| Notion | \`notion\` |
|
||||
| Linear | \`linear\` |
|
||||
| Jira | \`jira\` |
|
||||
| Asana | \`asana\` |
|
||||
| Trello | \`trello\` |
|
||||
| HubSpot | \`hubspot\` |
|
||||
| Salesforce | \`salesforce\` |
|
||||
| LinkedIn | \`linkedin\` |
|
||||
| X (Twitter) | \`twitter\` |
|
||||
| Reddit | \`reddit\` |
|
||||
| Dropbox | \`dropbox\` |
|
||||
| OneDrive | \`onedrive\` |
|
||||
| Microsoft Outlook | \`microsoft_outlook\` |
|
||||
| Microsoft Teams | \`microsoft_teams\` |
|
||||
| Calendly | \`calendly\` |
|
||||
| Cal.com | \`cal\` |
|
||||
| Intercom | \`intercom\` |
|
||||
| Zendesk | \`zendesk\` |
|
||||
| Airtable | \`airtable\` |
|
||||
|
||||
**IMPORTANT:** Always use these exact slugs. Do NOT guess — e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`.
|
||||
|
||||
## Critical: Check First, Connect Second
|
||||
|
||||
**BEFORE calling composio-connect-toolkit, ALWAYS check if the service is already connected.** The system prompt includes a "Currently connected" list. If the service is there, skip connecting and go straight to search + execute.
|
||||
|
||||
**Flow:**
|
||||
1. Check if the service is in the "Currently connected" list (in the system prompt above)
|
||||
2. If **connected** → go directly to step 4
|
||||
3. If **NOT connected** → call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue
|
||||
4. Call \`composio-search-tools\` with SHORT keyword queries
|
||||
5. Read the \`inputSchema\` from results — note \`required\` fields
|
||||
6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments
|
||||
|
||||
**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI.
|
||||
|
||||
## Search Query Tips
|
||||
|
||||
Use **short keyword queries**, not full sentences:
|
||||
|
||||
| ✅ Good | ❌ Bad |
|
||||
|---------|--------|
|
||||
| "list issues" | "get all open issues for a GitHub repository" |
|
||||
| "send email" | "send an email to someone using Gmail" |
|
||||
| "get profile" | "fetch the authenticated user's profile details" |
|
||||
| "create spreadsheet" | "create a new Google Sheets spreadsheet with data" |
|
||||
|
||||
If the first search returns 0 results, try a different short query (e.g., "issues" instead of "list issues").
|
||||
|
||||
## Passing Arguments
|
||||
|
||||
**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters.
|
||||
|
||||
- Read the \`inputSchema\` from search results carefully
|
||||
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" → \`owner: "rowboatlabs", repo: "rowboat"\`)
|
||||
- For tools with empty \`properties: {}\`, pass \`arguments: {}\`
|
||||
- For tools with required fields, pass all of them
|
||||
|
||||
### Example: GitHub Issues
|
||||
|
||||
User says: "Get me the open issues on rowboatlabs/rowboat"
|
||||
|
||||
1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\`
|
||||
→ finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
|
||||
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`
|
||||
|
||||
### Example: Gmail Fetch
|
||||
|
||||
User says: "What's my latest email?"
|
||||
|
||||
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
|
||||
→ finds \`GMAIL_FETCH_EMAILS\`
|
||||
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
|
||||
|
||||
### Example: LinkedIn Profile (no-arg tool)
|
||||
|
||||
User says: "Get my LinkedIn profile"
|
||||
|
||||
1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\`
|
||||
→ finds \`LINKEDIN_GET_MY_INFO\` with properties: {}
|
||||
2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\`
|
||||
|
||||
## Error Recovery
|
||||
|
||||
- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user.
|
||||
- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service.
|
||||
- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection.
|
||||
|
||||
## Multi-Part Requests
|
||||
|
||||
When the user says "connect X and then do Y" — complete BOTH parts in one turn:
|
||||
1. If X is already connected (check the connected list), skip to Y immediately
|
||||
2. If X needs connecting, connect it, then proceed to Y after authentication
|
||||
|
||||
## Confirmation Rules
|
||||
|
||||
- **Read-only actions** (fetch, list, get, search): Execute without asking
|
||||
- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing
|
||||
- **Connecting a toolkit**: Always safe — just do it when needed
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The track agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Tracks Skill
|
||||
|
||||
You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
|
||||
|
||||
## First: Just Do It — Do Not Ask About Edit Mode
|
||||
|
||||
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
|
||||
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
|
||||
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
|
||||
- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact.
|
||||
|
||||
## What Is a Track Block
|
||||
|
||||
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
|
||||
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
|
||||
- A sibling "target region" — an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
|
||||
|
||||
**Concrete example** (a track that shows the current time in Chicago every hour):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: chicago-time
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
<!--/track-target:chicago-time-->
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
- News digests or headlines
|
||||
- Stock or crypto prices
|
||||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Any recurring summary that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
Each track has two parts that live next to each other in the note:
|
||||
|
||||
1. The ` + "`" + `track` + "`" + ` code fence — contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
|
||||
2. The target-comment region — ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
|
||||
|
||||
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a track block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
` + "```" + `
|
||||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Choosing a trackId
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
- **Must be unique within the note file.** Before inserting, read the file and check:
|
||||
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
|
||||
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
|
||||
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
|
||||
- Don't reuse an old ID even if the previous block was deleted — pick a fresh one.
|
||||
|
||||
## Writing a Good Instruction
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to fetch or compute.
|
||||
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
|
||||
- **Imperative voice, 1-3 sentences.**
|
||||
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
|
||||
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction — not this chat, not what you produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the user is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **Bundling multiple purposes** into one instruction — split into separate track blocks.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
|
||||
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
Note: when a location is in DST, reflect that in the offset column.
|
||||
eventMatchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line.
|
||||
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
|
||||
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields with no newline needs:
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: "Show the current time in Chicago, IL in 12-hour format."
|
||||
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
|
||||
` + "```" + `
|
||||
|
||||
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
|
||||
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
|
||||
|
||||
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: 'He said "hi" at 9:00.'
|
||||
` + "```" + `
|
||||
|
||||
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
|
||||
- No other escape sequences work.
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not.
|
||||
|
||||
### Editing an existing track
|
||||
|
||||
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Schedules
|
||||
|
||||
Schedule is an **optional** discriminated union. Three types:
|
||||
|
||||
### ` + "`" + `cron` + "`" + ` — recurring at exact times
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
|
||||
|
||||
### ` + "`" + `window` + "`" + ` — recurring within a time-of-day range
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: window
|
||||
cron: "0 0 * * 1-5"
|
||||
startTime: "09:00"
|
||||
endTime: "17:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `–` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" — flexible timing with bounds.
|
||||
|
||||
### ` + "`" + `once` + "`" + ` — one-shot at a future time
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: once
|
||||
runAt: "2026-04-14T09:00:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
- ` + "`" + `"*/15 * * * *"` + "`" + ` — every 15 minutes
|
||||
- ` + "`" + `"0 * * * *"` + "`" + ` — every hour on the hour
|
||||
- ` + "`" + `"0 8 * * *"` + "`" + ` — daily at 8am
|
||||
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` — weekdays at 9am
|
||||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** — the user triggers it via the Play button in the UI.
|
||||
|
||||
## Event Triggers (third trigger type)
|
||||
|
||||
In addition to manual and scheduled, a track can be triggered by **events** — incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: q3-planning-emails
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3
|
||||
planning, drawn from emails on the topic.
|
||||
active: true
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How it works:
|
||||
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
|
||||
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
When to suggest event triggers:
|
||||
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
|
||||
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
|
||||
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined — a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
|
||||
|
||||
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
|
||||
- Be descriptive but not overly narrow — Pass 1 routing is liberal by design.
|
||||
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
|
||||
|
||||
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely — they'll only run on schedule or manually.
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Cmd+K with cursor context
|
||||
|
||||
When the user invokes Cmd+K, the context includes an attachment mention like:
|
||||
> User has attached the following files:
|
||||
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
|
||||
|
||||
Workflow:
|
||||
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
|
||||
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` — always re-read fresh.
|
||||
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
|
||||
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
|
||||
5. Construct the full track block (YAML + target pair).
|
||||
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should I add the track to?"
|
||||
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
|
||||
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
|
||||
|
||||
### No note context at all
|
||||
|
||||
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
|
||||
|
||||
### Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
|
||||
|
||||
## The Exact Text to Insert
|
||||
|
||||
Write it verbatim like this (including the blank line between fence and target):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <id>
|
||||
instruction: |
|
||||
<instruction, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<id>-->
|
||||
<!--/track-target:<id>-->
|
||||
|
||||
**Rules:**
|
||||
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
|
||||
- Target pair is **empty on creation**. The runner fills it on the first run.
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `.
|
||||
- Use 2-space YAML indent. No tabs.
|
||||
- Top-level markdown only — never inside a code fence, blockquote, or table.
|
||||
|
||||
## After Insertion
|
||||
|
||||
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
|
||||
- **Then offer to run it once now** (see "Running a Track" below) — especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
|
||||
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself — use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
|
||||
|
||||
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
|
||||
|
||||
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button — but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
|
||||
|
||||
### When to proactively offer to run
|
||||
|
||||
These are upsells — ask first, don't run silently.
|
||||
|
||||
- **Just created a new track block.** Before declaring done, offer:
|
||||
> "Want me to run it once now to seed the initial content?"
|
||||
|
||||
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) — otherwise the target region stays empty until the next matching event arrives.
|
||||
|
||||
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
|
||||
|
||||
- **Just edited an existing track.** Offer:
|
||||
> "Want me to run it now to see the updated output?"
|
||||
|
||||
- **Explicit user request.** "run the X track", "test it", "refresh that block" → call the tool directly.
|
||||
|
||||
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
|
||||
|
||||
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- New track: "Track emails about Q3 planning" → after creating it, run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
|
||||
|
||||
- New track: "Summarize this week's customer calls" → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
|
||||
- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context — it can mislead the agent.
|
||||
|
||||
### What to do with the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- **` + "`" + `action: 'replace'` + "`" + `** → the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
|
||||
> "Done — track now shows: 72°F, partly cloudy in Chicago."
|
||||
|
||||
- **` + "`" + `action: 'no_update'` + "`" + `** → the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
|
||||
|
||||
- **` + "`" + `error` + "`" + ` set** → surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't auto-run** after every edit — ask first.
|
||||
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
|
||||
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
|
||||
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn — one run per user-facing action.
|
||||
|
||||
## Proactive Suggestions
|
||||
|
||||
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
|
||||
- "I want to track / monitor / watch / keep an eye on / follow X"
|
||||
- "Can you check on X every morning / hourly / weekly?"
|
||||
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
|
||||
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
|
||||
|
||||
Suggestion style — one line, concrete:
|
||||
> "I can turn this into a track block that refreshes hourly — want that?"
|
||||
|
||||
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
|
||||
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
|
||||
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` — runtime-managed.
|
||||
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` — local time only.
|
||||
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file — always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Track
|
||||
|
||||
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
|
||||
|
||||
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
|
||||
|
||||
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <kebab-id>
|
||||
instruction: |
|
||||
<what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<kebab-id>-->
|
||||
<!--/track-target:<kebab-id>-->
|
||||
|
||||
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
|
||||
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js';
|
||||
|
||||
export interface IBrowserControlService {
|
||||
execute(
|
||||
input: BrowserControlInput,
|
||||
ctx?: { signal?: AbortSignal },
|
||||
): Promise<BrowserControlResult>;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { createReadStream } from "fs";
|
||||
import { createInterface } from "readline";
|
||||
import { execSync } from "child_process";
|
||||
import { glob } from "glob";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
|
|
@ -12,6 +14,10 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
|
|||
import * as workspace from "../../workspace/workspace.js";
|
||||
import { IAgentsRepo } from "../../agents/repo.js";
|
||||
import { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
|
|
@ -20,6 +26,8 @@ import { isSignedIn } from "../../account/account.js";
|
|||
import { getGatewayProvider } from "../../models/gateway.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
||||
import type { IBrowserControlService } from "../browser-control/service.js";
|
||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||
// Import paths are computed so esbuild cannot statically resolve them.
|
||||
|
|
@ -184,14 +192,119 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
'workspace-readFile': {
|
||||
description: 'Read file contents from the workspace. Supports utf8, base64, and binary encodings.',
|
||||
description: 'Read a file from the workspace. For text files (utf8, the default), returns the content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use the `offset` and `limit` parameters to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers in the output are display-only — do NOT include them when later writing or editing the file. For `base64` / `binary` encodings, returns the raw bytes as a string and ignores `offset` / `limit`.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'),
|
||||
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'),
|
||||
}),
|
||||
execute: async ({ path: relPath, encoding = 'utf8' }: { path: string; encoding?: 'utf8' | 'base64' | 'binary' }) => {
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
offset,
|
||||
limit,
|
||||
encoding = 'utf8',
|
||||
}: {
|
||||
path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
encoding?: 'utf8' | 'base64' | 'binary';
|
||||
}) => {
|
||||
try {
|
||||
return await workspace.readFile(relPath, encoding);
|
||||
if (encoding !== 'utf8') {
|
||||
return await workspace.readFile(relPath, encoding);
|
||||
}
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000;
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
|
||||
const MAX_BYTES = 50 * 1024;
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
|
||||
|
||||
const absPath = workspace.resolveWorkspacePath(relPath);
|
||||
const stats = await fs.lstat(absPath);
|
||||
const stat = workspace.statToSchema(stats, 'file');
|
||||
const etag = workspace.computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
const effectiveOffset = offset ?? 1;
|
||||
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
|
||||
const start = effectiveOffset - 1;
|
||||
|
||||
const stream = createReadStream(absPath, { encoding: 'utf8' });
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
const collected: string[] = [];
|
||||
let totalLines = 0;
|
||||
let bytes = 0;
|
||||
let truncatedByBytes = false;
|
||||
let hasMoreLines = false;
|
||||
|
||||
try {
|
||||
for await (const text of rl) {
|
||||
totalLines += 1;
|
||||
if (totalLines <= start) continue;
|
||||
|
||||
if (collected.length >= effectiveLimit) {
|
||||
hasMoreLines = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH
|
||||
? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
|
||||
: text;
|
||||
const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0);
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
truncatedByBytes = true;
|
||||
hasMoreLines = true;
|
||||
break;
|
||||
}
|
||||
|
||||
collected.push(line);
|
||||
bytes += size;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) {
|
||||
return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` };
|
||||
}
|
||||
|
||||
const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`);
|
||||
const lastReadLine = effectiveOffset + collected.length - 1;
|
||||
const nextOffset = lastReadLine + 1;
|
||||
|
||||
let footer: string;
|
||||
if (truncatedByBytes) {
|
||||
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`;
|
||||
} else if (hasMoreLines) {
|
||||
footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`;
|
||||
} else {
|
||||
footer = `(End of file - total ${totalLines} lines)`;
|
||||
}
|
||||
|
||||
const content = [
|
||||
`<path>${relPath}</path>`,
|
||||
`<type>file</type>`,
|
||||
`<content>`,
|
||||
prefixed.join('\n'),
|
||||
'',
|
||||
footer,
|
||||
`</content>`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
path: relPath,
|
||||
encoding: 'utf8' as const,
|
||||
content,
|
||||
stat,
|
||||
etag,
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
totalLines,
|
||||
hasMore: hasMoreLines || truncatedByBytes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -468,7 +581,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
count: matches.length,
|
||||
tool: 'ripgrep',
|
||||
};
|
||||
} catch (rgError) {
|
||||
} catch {
|
||||
// Fallback to basic grep if ripgrep not available or failed
|
||||
const grepArgs = [
|
||||
'-rn',
|
||||
|
|
@ -903,6 +1016,39 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Browser Control
|
||||
// ============================================================================
|
||||
|
||||
'browser-control': {
|
||||
description: 'Control the embedded browser pane. Read the current page, inspect indexed interactable elements, and navigate/click/type/press keys in the active browser tab.',
|
||||
inputSchema: BrowserControlInputSchema,
|
||||
isAvailable: async () => {
|
||||
try {
|
||||
container.resolve<IBrowserControlService>('browserControlService');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async (input: BrowserControlInput, ctx?: ToolContext) => {
|
||||
try {
|
||||
const browserControlService = container.resolve<IBrowserControlService>('browserControlService');
|
||||
return await browserControlService.execute(input, { signal: ctx?.signal });
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
action: input.action,
|
||||
error: error instanceof Error ? error.message : 'Browser control is unavailable.',
|
||||
browser: {
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// App Navigation
|
||||
// ============================================================================
|
||||
|
|
@ -1043,123 +1189,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
// ============================================================================
|
||||
// Web Search (Brave Search API)
|
||||
// Web Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'web-search': {
|
||||
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
try {
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
|
||||
try {
|
||||
const resultCount = Math.min(Math.max(count || 5, 1), 20);
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(resultCount),
|
||||
});
|
||||
if (freshness) {
|
||||
params.set('freshness', freshness);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (await isSignedIn()) {
|
||||
// Use proxy
|
||||
const accessToken = await getAccessToken();
|
||||
response = await fetch(`${API_URL}/v1/search/brave?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Read API key from config
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
|
||||
headers: {
|
||||
'X-Subscription-Token': apiKey,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `Brave Search API error (${response.status}): ${body}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
|
||||
};
|
||||
|
||||
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
description: r.description || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
results,
|
||||
count: results.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Research Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'research-search': {
|
||||
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
|
||||
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
|
||||
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
|
|
@ -1185,7 +1223,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
highlights: true,
|
||||
},
|
||||
};
|
||||
if (category) {
|
||||
if (category && category !== 'general') {
|
||||
reqBody.category = category;
|
||||
}
|
||||
|
||||
|
|
@ -1214,14 +1252,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
|
||||
error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "<your-key>" }`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
|
||||
error: `Exa Search API key is empty. Set "apiKey" in ${exaConfigPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1277,4 +1315,225 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
},
|
||||
},
|
||||
'save-to-memory': {
|
||||
description: "Save a note about the user to the agent memory inbox. Use this when you observe something worth remembering — their preferences, communication patterns, relationship context, scheduling habits, or explicit instructions about how they want things done.",
|
||||
inputSchema: z.object({
|
||||
note: z.string().describe("The observation or preference to remember. Be specific and concise."),
|
||||
}),
|
||||
execute: async ({ note }: { note: string }) => {
|
||||
const inboxPath = path.join(WorkDir, 'knowledge', 'Agent Notes', 'inbox.md');
|
||||
const dir = path.dirname(inboxPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = `\n- [${timestamp}] ${note}\n`;
|
||||
|
||||
await fs.appendFile(inboxPath, entry, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Saved to memory: ${note}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Composio Meta-Tools
|
||||
// ========================================================================
|
||||
|
||||
'composio-list-toolkits': {
|
||||
description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.',
|
||||
inputSchema: z.object({
|
||||
category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support']).optional()
|
||||
.describe('Filter by category. Defaults to "all".'),
|
||||
}),
|
||||
execute: async ({ category }: { category?: string }) => {
|
||||
const toolkits = CURATED_TOOLKITS
|
||||
.filter(t => !category || category === 'all' || t.category === category)
|
||||
.map(t => ({
|
||||
slug: t.slug,
|
||||
name: t.displayName,
|
||||
category: t.category,
|
||||
isConnected: composioAccountsRepo.isConnected(t.slug),
|
||||
}));
|
||||
|
||||
const connectedCount = toolkits.filter(t => t.isConnected).length;
|
||||
return {
|
||||
toolkits,
|
||||
connectedCount,
|
||||
totalCount: toolkits.length,
|
||||
};
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
|
||||
'composio-search-tools': {
|
||||
description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'),
|
||||
toolkitSlug: z.string().optional().describe('Optional: limit search to a specific toolkit (e.g., "gmail", "github")'),
|
||||
}),
|
||||
execute: async ({ query, toolkitSlug }: { query: string; toolkitSlug?: string }) => {
|
||||
try {
|
||||
const toolkitFilter = toolkitSlug ? [toolkitSlug] : undefined;
|
||||
const result = await searchComposioTools(query, toolkitFilter);
|
||||
|
||||
// Filter to curated toolkits only (skip if a specific toolkit was requested —
|
||||
// the API already filtered server-side)
|
||||
const filtered = toolkitSlug
|
||||
? result.items
|
||||
: result.items.filter(t => CURATED_TOOLKIT_SLUGS.has(t.toolkitSlug));
|
||||
|
||||
// Annotate with connection status
|
||||
const tools = filtered.map(t => ({
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
toolkitSlug: t.toolkitSlug,
|
||||
isConnected: composioAccountsRepo.isConnected(t.toolkitSlug),
|
||||
inputSchema: t.inputParameters,
|
||||
}));
|
||||
|
||||
return {
|
||||
tools,
|
||||
resultCount: tools.length,
|
||||
hint: tools.some(t => !t.isConnected)
|
||||
? 'Some tools require connecting the toolkit first. Use composio-connect-toolkit to help the user authenticate.'
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { tools: [], resultCount: 0, error: message };
|
||||
}
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
|
||||
'composio-execute-tool': {
|
||||
description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })',
|
||||
inputSchema: z.object({
|
||||
toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'),
|
||||
toolkitSlug: z.string().describe('The toolkit slug (e.g., "gmail", "github")'),
|
||||
arguments: z.record(z.string(), z.unknown()).describe('REQUIRED: Tool input parameters as key-value pairs. Get the required fields from the inputSchema returned by composio-search-tools. Never omit this.'),
|
||||
}),
|
||||
execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record<string, unknown> }) => {
|
||||
// Default arguments to {} if the LLM omits the field entirely
|
||||
const toolArgs = args ?? {};
|
||||
|
||||
// Check connection
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
successful: false,
|
||||
data: null,
|
||||
error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await executeComposioAction(toolSlug, {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: toolArgs,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Composio] Tool execution failed for ${toolSlug}:`, message);
|
||||
return {
|
||||
successful: false,
|
||||
data: null,
|
||||
error: `Failed to execute ${toolSlug}: ${message}. If fields are missing, check the inputSchema and retry with the correct arguments.`,
|
||||
};
|
||||
}
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
|
||||
'composio-connect-toolkit': {
|
||||
description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Shows a connect card for the user to authenticate.',
|
||||
inputSchema: z.object({
|
||||
toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'),
|
||||
}),
|
||||
execute: async ({ toolkitSlug }: { toolkitSlug: string }) => {
|
||||
// Validate against curated list
|
||||
if (!CURATED_TOOLKIT_SLUGS.has(toolkitSlug)) {
|
||||
const available = CURATED_TOOLKITS.map(t => `${t.slug} (${t.displayName})`).join(', ');
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already connected
|
||||
if (composioAccountsRepo.isConnected(toolkitSlug)) {
|
||||
return {
|
||||
success: true,
|
||||
message: `${toolkitSlug} is already connected. You can search for and execute its tools.`,
|
||||
alreadyConnected: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Return signal — the UI renders a ComposioConnectCard with a Connect button.
|
||||
// OAuth only starts when the user clicks that button.
|
||||
const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug);
|
||||
return {
|
||||
success: true,
|
||||
message: `Please connect ${toolkit?.displayName ?? toolkitSlug} to continue.`,
|
||||
};
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
'update-track-content': {
|
||||
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
|
||||
trackId: z.string().describe("The track block's trackId"),
|
||||
content: z.string().describe("The new content to place inside the track's target region"),
|
||||
}),
|
||||
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
|
||||
try {
|
||||
await updateContent(filePath, trackId, content);
|
||||
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
|
||||
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'run-track-block': {
|
||||
description: "Manually trigger a track block to run now. Equivalent to the user clicking the Play button on the block, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking block from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new content.",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
|
||||
trackId: z.string().describe("The track block's trackId (must exist in the file)"),
|
||||
context: z.string().optional().describe(
|
||||
"Optional extra context for the track agent to consider for THIS run only — does not modify the block's instruction. " +
|
||||
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
|
||||
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
|
||||
"Omit for a plain refresh."
|
||||
),
|
||||
}),
|
||||
execute: async ({ filePath, trackId, context }: { filePath: string; trackId: string; context?: string }) => {
|
||||
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
|
||||
try {
|
||||
// Lazy import to break a module-init cycle:
|
||||
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
|
||||
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
|
||||
const result = await triggerTrackUpdate(trackId, knowledgeRelativePath, context, 'manual');
|
||||
return {
|
||||
success: !result.error,
|
||||
runId: result.runId,
|
||||
action: result.action,
|
||||
summary: result.summary,
|
||||
contentAfter: result.contentAfter,
|
||||
error: result.error,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import z from "zod";
|
|||
|
||||
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||
export type VoiceOutputMode = 'summary' | 'full';
|
||||
export type MiddlePaneContext =
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string };
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
|
|
@ -11,10 +14,11 @@ type EnqueuedMessage = {
|
|||
voiceInput?: boolean;
|
||||
voiceOutput?: VoiceOutputMode;
|
||||
searchEnabled?: boolean;
|
||||
middlePaneContext?: MiddlePaneContext;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +34,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
@ -41,6 +45,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
voiceInput,
|
||||
voiceOutput,
|
||||
searchEnabled,
|
||||
middlePaneContext,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
/**
|
||||
* Parse the YAML frontmatter from the input string. Returns the frontmatter and content.
|
||||
* @param input - The input string to parse.
|
||||
* @returns The frontmatter and content.
|
||||
*/
|
||||
export function parseFrontmatter(input: string): {
|
||||
frontmatter: unknown | null;
|
||||
content: string;
|
||||
} {
|
||||
if (input.startsWith("---")) {
|
||||
const end = input.indexOf("\n---", 3);
|
||||
|
||||
if (end !== -1) {
|
||||
const fm = input.slice(3, end).trim(); // YAML text
|
||||
return {
|
||||
frontmatter: parseYaml(fm),
|
||||
content: input.slice(end + 4).trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
frontmatter: null,
|
||||
content: input,
|
||||
};
|
||||
}
|
||||
|
|
@ -37,9 +37,10 @@ function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
|
|||
*/
|
||||
export async function discoverConfiguration(
|
||||
issuerUrl: string,
|
||||
clientId: string
|
||||
clientId: string,
|
||||
clientSecret?: string
|
||||
): Promise<client.Configuration> {
|
||||
const cacheKey = `${issuerUrl}:${clientId}`;
|
||||
const cacheKey = `${issuerUrl}:${clientId}:${clientSecret ? 'secret' : 'none'}`;
|
||||
|
||||
const cached = configCache.get(cacheKey);
|
||||
if (cached) {
|
||||
|
|
@ -50,8 +51,8 @@ export async function discoverConfiguration(
|
|||
const config = await client.discovery(
|
||||
new URL(issuerUrl),
|
||||
clientId,
|
||||
undefined, // no client_secret (PKCE flow)
|
||||
client.None(), // PKCE doesn't require client authentication
|
||||
clientSecret ?? undefined,
|
||||
clientSecret ? client.ClientSecretPost(clientSecret) : client.None(),
|
||||
{
|
||||
execute: [client.allowInsecureRequests],
|
||||
}
|
||||
|
|
@ -69,7 +70,8 @@ export function createStaticConfiguration(
|
|||
authorizationEndpoint: string,
|
||||
tokenEndpoint: string,
|
||||
clientId: string,
|
||||
revocationEndpoint?: string
|
||||
revocationEndpoint?: string,
|
||||
clientSecret?: string
|
||||
): client.Configuration {
|
||||
console.log(`[OAuth] Creating static configuration (no discovery)`);
|
||||
|
||||
|
|
@ -86,8 +88,8 @@ export function createStaticConfiguration(
|
|||
return new client.Configuration(
|
||||
serverMetadata,
|
||||
clientId,
|
||||
undefined, // no client_secret
|
||||
client.None() // PKCE auth
|
||||
clientSecret ?? undefined,
|
||||
clientSecret ? client.ClientSecretPost(clientSecret) : client.None()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -214,12 +216,15 @@ export async function refreshTokens(
|
|||
return tokens;
|
||||
}
|
||||
|
||||
const EXPIRY_MARGIN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Check if tokens are expired
|
||||
* Check if tokens are expired. Treats tokens as expired EXPIRY_MARGIN_SECONDS
|
||||
* before the real expiry to absorb clock skew and in-flight request latency.
|
||||
*/
|
||||
export function isTokenExpired(tokens: OAuthTokens): boolean {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return tokens.expires_at <= now;
|
||||
return tokens.expires_at <= now + EXPIRY_MARGIN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { SUPABASE_PROJECT_URL } from '../config/env.js';
|
||||
import { getRowboatConfig } from '../config/rowboat.js';
|
||||
|
||||
/**
|
||||
* Discovery configuration - how to get OAuth endpoints
|
||||
|
|
@ -55,7 +55,7 @@ const providerConfigs: ProviderConfig = {
|
|||
rowboat: {
|
||||
discovery: {
|
||||
mode: 'issuer',
|
||||
issuer: `${SUPABASE_PROJECT_URL}/auth/v1/.well-known/oauth-authorization-server`,
|
||||
issuer: "TBD",
|
||||
},
|
||||
client: {
|
||||
mode: 'dcr',
|
||||
|
|
@ -98,21 +98,21 @@ const providerConfigs: ProviderConfig = {
|
|||
/**
|
||||
* Get provider configuration by name
|
||||
*/
|
||||
export function getProviderConfig(providerName: string): ProviderConfigEntry {
|
||||
export async function getProviderConfig(providerName: string): Promise<ProviderConfigEntry> {
|
||||
const config = providerConfigs[providerName];
|
||||
if (!config) {
|
||||
throw new Error(`Unknown OAuth provider: ${providerName}`);
|
||||
}
|
||||
if (providerName === 'rowboat') {
|
||||
const rowboatConfig = await getRowboatConfig();
|
||||
config.discovery = {
|
||||
mode: 'issuer',
|
||||
issuer: `${rowboatConfig.supabaseUrl}/auth/v1/.well-known/oauth-authorization-server`,
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider configurations
|
||||
*/
|
||||
export function getAllProviderConfigs(): ProviderConfig {
|
||||
return providerConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all configured OAuth providers
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import z from 'zod';
|
|||
const ProviderConnectionSchema = z.object({
|
||||
tokens: OAuthTokens.nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
clientSecret: z.string().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ const OAuthConfigSchema = z.object({
|
|||
const ClientFacingConfigSchema = z.record(z.string(), z.object({
|
||||
connected: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
}));
|
||||
|
||||
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
|
||||
|
|
@ -111,8 +113,9 @@ export class FSOAuthRepo implements IOAuthRepo {
|
|||
clientFacingConfig[provider] = {
|
||||
connected: !!providerConfig.tokens,
|
||||
error: providerConfig.error,
|
||||
clientId: providerConfig.clientId ?? null,
|
||||
};
|
||||
}
|
||||
return clientFacingConfig;
|
||||
return ClientFacingConfigSchema.parse(clientFacingConfig);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,23 +3,17 @@ import { IOAuthRepo } from './repo.js';
|
|||
import { IClientRegistrationRepo } from './client-repo.js';
|
||||
import { getProviderConfig } from './providers.js';
|
||||
import * as oauthClient from './oauth-client.js';
|
||||
import { OAuthTokens } from './types.js';
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read('rowboat');
|
||||
if (!tokens) {
|
||||
throw new Error('Not signed into Rowboat');
|
||||
}
|
||||
|
||||
if (!oauthClient.isTokenExpired(tokens)) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
let refreshInFlight: Promise<OAuthTokens> | null = null;
|
||||
|
||||
async function performRefresh(tokens: OAuthTokens): Promise<OAuthTokens> {
|
||||
console.log("Refreshing rowboat access token");
|
||||
if (!tokens.refresh_token) {
|
||||
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
|
||||
}
|
||||
|
||||
const providerConfig = getProviderConfig('rowboat');
|
||||
const providerConfig = await getProviderConfig('rowboat');
|
||||
if (providerConfig.discovery.mode !== 'issuer') {
|
||||
throw new Error('Rowboat provider requires issuer discovery mode');
|
||||
}
|
||||
|
|
@ -40,7 +34,29 @@ export async function getAccessToken(): Promise<string> {
|
|||
tokens.refresh_token,
|
||||
tokens.scopes,
|
||||
);
|
||||
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
await oauthRepo.upsert('rowboat', { tokens: refreshed });
|
||||
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read('rowboat');
|
||||
if (!tokens) {
|
||||
throw new Error('Not signed into Rowboat');
|
||||
}
|
||||
|
||||
if (!oauthClient.isTokenExpired(tokens)) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = performRefresh(tokens).finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
const refreshed = await refreshInFlight;
|
||||
return refreshed.access_token;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface BillingInfo {
|
|||
userId: string | null;
|
||||
subscriptionPlan: string | null;
|
||||
subscriptionStatus: string | null;
|
||||
trialExpiresAt: string | null;
|
||||
sanctionedCredits: number;
|
||||
availableCredits: number;
|
||||
}
|
||||
|
|
@ -26,6 +27,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
billing: {
|
||||
plan: string | null;
|
||||
status: string | null;
|
||||
trialExpiresAt: string | null;
|
||||
usage: {
|
||||
sanctionedCredits: number;
|
||||
availableCredits: number;
|
||||
|
|
@ -37,6 +39,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
userId: body.user.id ?? null,
|
||||
subscriptionPlan: body.billing.plan,
|
||||
subscriptionStatus: body.billing.status,
|
||||
trialExpiresAt: body.billing.trialExpiresAt ?? null,
|
||||
sanctionedCredits: body.billing.usage.sanctionedCredits,
|
||||
availableCredits: body.billing.usage.availableCredits,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import {
|
|||
ZExecuteActionRequest,
|
||||
ZExecuteActionResponse,
|
||||
ZListResponse,
|
||||
ZTool,
|
||||
ZSearchResultTool,
|
||||
ZToolkit,
|
||||
type NormalizedToolResult,
|
||||
} from "./types.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getAccessToken } from "../auth/tokens.js";
|
||||
|
|
@ -72,7 +73,7 @@ function loadConfig(): ComposioConfig {
|
|||
/**
|
||||
* Save Composio configuration
|
||||
*/
|
||||
export function saveConfig(config: ComposioConfig): void {
|
||||
function saveConfig(config: ComposioConfig): void {
|
||||
const dir = path.dirname(CONFIG_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
|
@ -167,7 +168,15 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
|
||||
// Try to extract a human-readable message from the JSON body
|
||||
let detail = '';
|
||||
try {
|
||||
const body = JSON.parse(rawText);
|
||||
if (typeof body?.error === 'string') detail = body.error;
|
||||
else if (typeof body?.message === 'string') detail = body.message;
|
||||
} catch { /* body isn't JSON or has no message field */ }
|
||||
const suffix = detail ? `: ${detail}` : '';
|
||||
throw new Error(`Composio API error: ${response.status} ${response.statusText}${suffix}`);
|
||||
}
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
|
|
@ -246,15 +255,6 @@ export async function createAuthConfig(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an auth config
|
||||
*/
|
||||
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
|
||||
return composioApiCall(ZDeleteOperationResponse, `/auth_configs/${authConfigId}`, {}, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connected account
|
||||
*/
|
||||
|
|
@ -284,20 +284,39 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
|
|||
}
|
||||
|
||||
/**
|
||||
* List available tools for a toolkit
|
||||
* Search for tools across all toolkits (or optionally filtered by specific toolkit slugs).
|
||||
* Returns tools with full input_parameters so the agent knows what params to pass.
|
||||
*
|
||||
* Uses a limit of 50 (not 15) to avoid the curated-filter-after-limit problem where
|
||||
* in-scope results at position 16+ would be discarded if earlier results are out-of-scope.
|
||||
*/
|
||||
export async function listToolkitTools(
|
||||
toolkitSlug: string,
|
||||
searchQuery: string | null = null,
|
||||
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
|
||||
export async function searchTools(
|
||||
searchQuery: string,
|
||||
toolkitSlugs?: string[],
|
||||
): Promise<{ items: NormalizedToolResult[] }> {
|
||||
const params: Record<string, string> = {
|
||||
toolkit_slug: toolkitSlug,
|
||||
limit: '200',
|
||||
query: searchQuery,
|
||||
limit: '50',
|
||||
};
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
if (toolkitSlugs && toolkitSlugs.length === 1) {
|
||||
params.toolkit_slug = toolkitSlugs[0];
|
||||
}
|
||||
return composioApiCall(ZListResponse(ZTool), "/tools", params);
|
||||
|
||||
const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params);
|
||||
|
||||
const items: NormalizedToolResult[] = result.items.map((item) => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
toolkitSlug: item.toolkit.slug,
|
||||
inputParameters: {
|
||||
type: 'object' as const,
|
||||
properties: item.input_parameters?.properties ?? {},
|
||||
required: item.input_parameters?.required,
|
||||
},
|
||||
}));
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import { ZToolkitMeta as ZSharedToolkitMeta, ZToolkitItem } from "@x/shared/dist/composio.js";
|
||||
|
||||
// Re-export the shared toolkit schemas so existing imports continue to work
|
||||
export const ZToolkitMeta = ZSharedToolkitMeta;
|
||||
|
||||
/**
|
||||
* Composio authentication schemes
|
||||
|
|
@ -29,26 +33,9 @@ export const ZConnectedAccountStatus = z.enum([
|
|||
]);
|
||||
|
||||
/**
|
||||
* Toolkit metadata
|
||||
* Toolkit schema — same shape as ZToolkitItem from shared, re-exported for convenience.
|
||||
*/
|
||||
export const ZToolkitMeta = z.object({
|
||||
description: z.string(),
|
||||
logo: z.string(),
|
||||
tools_count: z.number(),
|
||||
triggers_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolkit schema
|
||||
*/
|
||||
export const ZToolkit = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
meta: ZToolkitMeta,
|
||||
no_auth: z.boolean().optional(),
|
||||
auth_schemes: z.array(ZAuthScheme).optional(),
|
||||
composio_managed_auth_schemes: z.array(ZAuthScheme).optional(),
|
||||
});
|
||||
export const ZToolkit = ZToolkitItem;
|
||||
|
||||
/**
|
||||
* Tool schema
|
||||
|
|
@ -147,7 +134,7 @@ export const ZCreateConnectedAccountRequest = z.object({
|
|||
*/
|
||||
export const ZCreateConnectedAccountResponse = z.object({
|
||||
id: z.string(),
|
||||
connectionData: ZConnectionData,
|
||||
connectionData: ZConnectionData.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -227,12 +214,44 @@ export const ZLocalConnectedAccount = z.object({
|
|||
lastUpdatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type AuthScheme = z.infer<typeof ZAuthScheme>;
|
||||
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
|
||||
export type Toolkit = z.infer<typeof ZToolkit>;
|
||||
export type Tool = z.infer<typeof ZTool>;
|
||||
export type AuthConfig = z.infer<typeof ZAuthConfig>;
|
||||
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
|
||||
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
|
||||
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
|
||||
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;
|
||||
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
|
||||
|
||||
/**
|
||||
* Tool schema for search results.
|
||||
* Unlike ZTool, `toolkit` is optional because the Composio /tools search endpoint
|
||||
* sometimes omits the toolkit object from results. `input_parameters` uses
|
||||
* lenient defaults so tools with no params (e.g. LINKEDIN_GET_MY_INFO) parse cleanly.
|
||||
*/
|
||||
export const ZSearchResultTool = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
logo: z.string(),
|
||||
}),
|
||||
input_parameters: z.object({
|
||||
type: z.literal('object').optional().default('object'),
|
||||
properties: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional().default({ type: 'object', properties: {} }),
|
||||
}).passthrough();
|
||||
|
||||
/**
|
||||
* Normalized tool result returned from searchTools().
|
||||
*/
|
||||
export const ZNormalizedToolResult = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
toolkitSlug: z.string(),
|
||||
inputParameters: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.unknown()),
|
||||
required: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
export type NormalizedToolResult = z.infer<typeof ZNormalizedToolResult>;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,25 @@ import fs from "fs";
|
|||
import { homedir } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
function resolveWorkDir(): string {
|
||||
const configured = process.env.ROWBOAT_WORKDIR;
|
||||
if (!configured) {
|
||||
return path.join(homedir(), ".rowboat");
|
||||
}
|
||||
|
||||
const expanded = configured === "~"
|
||||
? homedir()
|
||||
: (configured.startsWith("~/") || configured.startsWith("~\\"))
|
||||
? path.join(homedir(), configured.slice(2))
|
||||
: configured;
|
||||
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage.
|
||||
// Normalize to an absolute path so workspace boundary checks behave consistently.
|
||||
export const WorkDir = resolveWorkDir();
|
||||
|
||||
// Get the directory of this file (for locating bundled assets)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -31,69 +48,13 @@ function ensureDefaultConfigs() {
|
|||
}
|
||||
}
|
||||
|
||||
// Welcome content inlined to work with bundled builds (esbuild changes __dirname)
|
||||
const WELCOME_CONTENT = `# Welcome to Rowboat
|
||||
|
||||
This vault is your work memory.
|
||||
|
||||
Rowboat extracts context from your emails and meetings and turns it into long-lived, editable Markdown notes. The goal is not to store everything, but to preserve the context that stays useful over time.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
**Entity-based notes**
|
||||
Notes represent people, projects, organizations, or topics that matter to your work.
|
||||
|
||||
**Auto-updating context**
|
||||
As new emails and meetings come in, Rowboat adds decisions, commitments, and relevant context to the appropriate notes.
|
||||
|
||||
**Living notes**
|
||||
These are not static summaries. Context accumulates over time, and notes evolve as your work evolves.
|
||||
|
||||
---
|
||||
|
||||
## Your AI coworker
|
||||
|
||||
Rowboat uses this shared memory to help with everyday work, such as:
|
||||
|
||||
- Drafting emails
|
||||
- Preparing for meetings
|
||||
- Summarizing the current state of a project
|
||||
- Taking local actions when appropriate
|
||||
|
||||
The AI works with deep context, but you stay in control. All notes are visible, editable, and yours.
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
**Reduce noise**
|
||||
Rowboat focuses on recurring contacts and active projects instead of trying to capture everything.
|
||||
|
||||
**Local and inspectable**
|
||||
All data is stored locally as plain Markdown. You can read, edit, or delete any file at any time.
|
||||
|
||||
**Built to improve over time**
|
||||
As you keep using Rowboat, context accumulates across notes instead of being reconstructed from scratch.
|
||||
|
||||
---
|
||||
|
||||
If something feels confusing or limiting, we'd love to hear about it.
|
||||
Rowboat is still evolving, and your workflow matters.
|
||||
`;
|
||||
|
||||
function ensureWelcomeFile() {
|
||||
// Create Welcome.md in knowledge directory if it doesn't exist
|
||||
const welcomeDest = path.join(WorkDir, "knowledge", "Welcome.md");
|
||||
if (!fs.existsSync(welcomeDest)) {
|
||||
fs.writeFileSync(welcomeDest, WELCOME_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
ensureDefaultConfigs();
|
||||
ensureWelcomeFile();
|
||||
|
||||
// Ensure default knowledge files exist
|
||||
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => {
|
||||
console.error('[DailyNote] Failed to ensure daily note:', err);
|
||||
});
|
||||
|
||||
// Initialize version history repo (async, fire-and-forget on startup)
|
||||
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,2 @@
|
|||
export const API_URL =
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
|
||||
export const SUPABASE_PROJECT_URL =
|
||||
process.env.SUPABASE_PROJECT_URL || 'https://jpxoiuhlshgwixajvsbu.supabase.co';
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
15
apps/x/packages/core/src/config/rowboat.ts
Normal file
15
apps/x/packages/core/src/config/rowboat.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
import { RowboatApiConfig } from "@x/shared/dist/rowboat-account.js";
|
||||
import { API_URL } from "./env.js";
|
||||
|
||||
let cached: z.infer<typeof RowboatApiConfig> | null = null;
|
||||
|
||||
export async function getRowboatConfig(): Promise<z.infer<typeof RowboatApiConfig>> {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const response = await fetch(`${API_URL}/v1/config`);
|
||||
const data = RowboatApiConfig.parse(await response.json());
|
||||
cached = data;
|
||||
return data;
|
||||
}
|
||||
|
|
@ -6,15 +6,40 @@ import { WorkDir } from "./config.js";
|
|||
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
|
||||
|
||||
const DEFAULT_ALLOW_LIST = [
|
||||
"agent-slack",
|
||||
"awk",
|
||||
"basename",
|
||||
"cat",
|
||||
"cut",
|
||||
"date",
|
||||
"df",
|
||||
"diff",
|
||||
"dirname",
|
||||
"du",
|
||||
"echo",
|
||||
"env",
|
||||
"file",
|
||||
"find",
|
||||
"grep",
|
||||
"head",
|
||||
"hostname",
|
||||
"jq",
|
||||
"ls",
|
||||
"printenv",
|
||||
"printf",
|
||||
"pwd",
|
||||
"yq",
|
||||
"whoami"
|
||||
"readlink",
|
||||
"realpath",
|
||||
"sort",
|
||||
"stat",
|
||||
"tail",
|
||||
"tree",
|
||||
"uname",
|
||||
"uniq",
|
||||
"wc",
|
||||
"which",
|
||||
"whoami",
|
||||
"yq"
|
||||
]
|
||||
|
||||
let cachedAllowList: string[] | null = null;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue