mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
commit
7ad1a91ea8
76 changed files with 9861 additions and 426 deletions
|
|
@ -102,6 +102,14 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca
|
||||||
| Workspace config | `apps/x/pnpm-workspace.yaml` |
|
| Workspace config | `apps/x/pnpm-workspace.yaml` |
|
||||||
| Root scripts | `apps/x/package.json` |
|
| 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
|
## Common Tasks
|
||||||
|
|
||||||
### LLM configuration (single provider)
|
### LLM configuration (single provider)
|
||||||
|
|
|
||||||
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.
|
||||||
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();
|
||||||
|
|
@ -44,6 +44,7 @@ export async function isConfigured(): Promise<{ configured: boolean }> {
|
||||||
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
|
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
composioClient.setApiKey(apiKey);
|
composioClient.setApiKey(apiKey);
|
||||||
|
invalidateCopilotInstructionsCache();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,15 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.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.
|
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||||
|
|
@ -362,6 +371,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 {
|
export function stopRunsWatcher(): void {
|
||||||
if (runsWatcher) {
|
if (runsWatcher) {
|
||||||
runsWatcher();
|
runsWatcher();
|
||||||
|
|
@ -433,7 +455,7 @@ export function setupIpcHandlers() {
|
||||||
return runsCore.createRun(args);
|
return runsCore.createRun(args);
|
||||||
},
|
},
|
||||||
'runs:createMessage': async (_event, 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) => {
|
'runs:authorizePermission': async (_event, args) => {
|
||||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||||
|
|
@ -758,9 +780,53 @@ export function setupIpcHandlers() {
|
||||||
'voice:synthesize': async (_event, args) => {
|
'voice:synthesize': async (_event, args) => {
|
||||||
return voice.synthesizeSpeech(args.text);
|
return voice.synthesizeSpeech(args.text);
|
||||||
},
|
},
|
||||||
|
// 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) };
|
||||||
|
}
|
||||||
|
},
|
||||||
// Billing handler
|
// Billing handler
|
||||||
'billing:getInfo': async () => {
|
'billing:getInfo': async () => {
|
||||||
return await getBillingInfo();
|
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 path from "node:path";
|
||||||
import {
|
import {
|
||||||
setupIpcHandlers,
|
setupIpcHandlers,
|
||||||
startRunsWatcher,
|
startRunsWatcher,
|
||||||
startServicesWatcher,
|
startServicesWatcher,
|
||||||
|
startTracksWatcher,
|
||||||
startWorkspaceWatcher,
|
startWorkspaceWatcher,
|
||||||
stopRunsWatcher,
|
stopRunsWatcher,
|
||||||
stopServicesWatcher,
|
stopServicesWatcher,
|
||||||
|
|
@ -22,11 +23,19 @@ 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 initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.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 { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
import { execSync, exec, execFileSync } from "node:child_process";
|
import { execSync, exec, execFileSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
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 execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -108,6 +117,30 @@ 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() {
|
function createWindow() {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
|
|
@ -127,26 +160,8 @@ function createWindow() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Grant microphone and display-capture permissions
|
configureSessionPermissions(session.defaultSession);
|
||||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||||
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' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show window when content is ready to prevent blank screen
|
// Show window when content is ready to prevent blank screen
|
||||||
win.once("ready-to-show", () => {
|
win.once("ready-to-show", () => {
|
||||||
|
|
@ -171,6 +186,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) {
|
if (app.isPackaged) {
|
||||||
win.loadURL("app://-/index.html");
|
win.loadURL("app://-/index.html");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -211,7 +230,10 @@ app.whenReady().then(async () => {
|
||||||
// Initialize all config files before UI can access them
|
// Initialize all config files before UI can access them
|
||||||
await initConfigs();
|
await initConfigs();
|
||||||
|
|
||||||
|
registerBrowserControlService(new ElectronBrowserControlService());
|
||||||
|
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
|
setupBrowserEventForwarding();
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
|
|
@ -228,6 +250,15 @@ app.whenReady().then(async () => {
|
||||||
// start services watcher
|
// start services watcher
|
||||||
startServicesWatcher();
|
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
|
// start gmail sync
|
||||||
initGmailSync();
|
initGmailSync();
|
||||||
|
|
||||||
|
|
@ -261,6 +292,11 @@ app.whenReady().then(async () => {
|
||||||
// start chrome extension sync server
|
// start chrome extension sync server
|
||||||
initChromeSync();
|
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", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
@ -279,4 +315,7 @@ app.on("before-quit", () => {
|
||||||
stopWorkspaceWatcher();
|
stopWorkspaceWatcher();
|
||||||
stopRunsWatcher();
|
stopRunsWatcher();
|
||||||
stopServicesWatcher();
|
stopServicesWatcher();
|
||||||
|
shutdownLocalSites().catch((error) => {
|
||||||
|
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
||||||
import { ipc as ipcShared } from '@x/shared';
|
import { ipc as ipcShared } from '@x/shared';
|
||||||
|
|
||||||
type InvokeChannels = ipcShared.InvokeChannels;
|
type InvokeChannels = ipcShared.InvokeChannels;
|
||||||
|
|
@ -55,4 +55,5 @@ contextBridge.exposeInMainWorld('ipc', ipc);
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronUtils', {
|
contextBridge.exposeInMainWorld('electronUtils', {
|
||||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||||
});
|
getZoomFactor: () => webFrame.getZoomFactor(),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"@tiptap/extension-image": "^3.16.0",
|
"@tiptap/extension-image": "^3.16.0",
|
||||||
"@tiptap/extension-link": "^3.15.3",
|
"@tiptap/extension-link": "^3.15.3",
|
||||||
"@tiptap/extension-placeholder": "^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-item": "^3.15.3",
|
||||||
"@tiptap/extension-task-list": "^3.15.3",
|
"@tiptap/extension-task-list": "^3.15.3",
|
||||||
"@tiptap/pm": "^3.15.3",
|
"@tiptap/pm": "^3.15.3",
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
||||||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react';
|
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
|
|
@ -15,6 +15,7 @@ import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-vi
|
||||||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||||
import { useDebounce } from './hooks/use-debounce';
|
import { useDebounce } from './hooks/use-debounce';
|
||||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||||
|
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
|
|
@ -55,7 +56,9 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
||||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||||
import { OnboardingModal } from '@/components/onboarding'
|
import { OnboardingModal } from '@/components/onboarding'
|
||||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||||
|
import { TrackModal } from '@/components/track-modal'
|
||||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||||
|
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
||||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
|
|
@ -86,7 +89,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||||
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||||
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
||||||
import * as analytics from '@/lib/analytics'
|
import * as analytics from '@/lib/analytics'
|
||||||
|
|
||||||
|
|
@ -127,6 +130,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
||||||
const TITLEBAR_BUTTONS_COLLAPSED = 4
|
const TITLEBAR_BUTTONS_COLLAPSED = 4
|
||||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
||||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||||
|
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
||||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||||
|
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
const clampNumber = (value: number, min: number, max: number) =>
|
||||||
|
|
@ -255,8 +259,63 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||||
|
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
|
||||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||||
|
|
||||||
|
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||||
|
const normalized = category?.trim().toLowerCase()
|
||||||
|
switch (normalized) {
|
||||||
|
case 'people':
|
||||||
|
case 'person':
|
||||||
|
return 'People'
|
||||||
|
case 'organizations':
|
||||||
|
case 'organization':
|
||||||
|
return 'Organizations'
|
||||||
|
case 'projects':
|
||||||
|
case 'project':
|
||||||
|
return 'Projects'
|
||||||
|
case 'meetings':
|
||||||
|
case 'meeting':
|
||||||
|
return 'Meetings'
|
||||||
|
case 'topics':
|
||||||
|
case 'topic':
|
||||||
|
default:
|
||||||
|
return 'Topics'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSuggestedTopicExplorePrompt = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
category?: string
|
||||||
|
}) => {
|
||||||
|
const folder = getSuggestedTopicTargetFolder(category)
|
||||||
|
const categoryLabel = category?.trim() || 'Topics'
|
||||||
|
return [
|
||||||
|
'I am exploring a suggested topic card from the Suggested Topics panel.',
|
||||||
|
'This card may represent a person, organization, topic, or project.',
|
||||||
|
'',
|
||||||
|
'Card context:',
|
||||||
|
`- Title: ${title}`,
|
||||||
|
`- Category: ${categoryLabel}`,
|
||||||
|
`- Description: ${description}`,
|
||||||
|
`- Target folder if we set this up: knowledge/${folder}/`,
|
||||||
|
'',
|
||||||
|
`Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`,
|
||||||
|
'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.',
|
||||||
|
'Do not create or modify anything yet.',
|
||||||
|
'Treat a clear confirmation from me as explicit approval to proceed.',
|
||||||
|
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
|
||||||
|
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
|
||||||
|
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||||
|
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||||
if (!usage) return null
|
if (!usage) return null
|
||||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||||
|
|
@ -437,6 +496,7 @@ type ViewState =
|
||||||
| { type: 'file'; path: string }
|
| { type: 'file'; path: string }
|
||||||
| { type: 'graph' }
|
| { type: 'graph' }
|
||||||
| { type: 'task'; name: string }
|
| { type: 'task'; name: string }
|
||||||
|
| { type: 'suggested-topics' }
|
||||||
|
|
||||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
if (a.type !== b.type) return false
|
if (a.type !== b.type) return false
|
||||||
|
|
@ -448,23 +508,20 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
|
|
||||||
/** Sidebar toggle + utility buttons (fixed position, top-left) */
|
/** Sidebar toggle + utility buttons (fixed position, top-left) */
|
||||||
function FixedSidebarToggle({
|
function FixedSidebarToggle({
|
||||||
onNewChat,
|
onNavigateBack,
|
||||||
onOpenSearch,
|
onNavigateForward,
|
||||||
meetingState,
|
canNavigateBack,
|
||||||
meetingSummarizing,
|
canNavigateForward,
|
||||||
meetingAvailable,
|
|
||||||
onToggleMeeting,
|
|
||||||
leftInsetPx,
|
leftInsetPx,
|
||||||
}: {
|
}: {
|
||||||
onNewChat: () => void
|
onNavigateBack: () => void
|
||||||
onOpenSearch: () => void
|
onNavigateForward: () => void
|
||||||
meetingState: MeetingTranscriptionState
|
canNavigateBack: boolean
|
||||||
meetingSummarizing: boolean
|
canNavigateForward: boolean
|
||||||
meetingAvailable: boolean
|
|
||||||
onToggleMeeting: () => void
|
|
||||||
leftInsetPx: number
|
leftInsetPx: number
|
||||||
}) {
|
}) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar, state } = useSidebar()
|
||||||
|
const isCollapsed = state === "collapsed"
|
||||||
return (
|
return (
|
||||||
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||||
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
||||||
|
|
@ -478,54 +535,29 @@ function FixedSidebarToggle({
|
||||||
>
|
>
|
||||||
<PanelLeftIcon className="size-5" />
|
<PanelLeftIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Back / Forward navigation */}
|
||||||
type="button"
|
{isCollapsed && (
|
||||||
onClick={onNewChat}
|
<>
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
<button
|
||||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
type="button"
|
||||||
aria-label="New chat"
|
onClick={onNavigateBack}
|
||||||
>
|
disabled={!canNavigateBack}
|
||||||
<SquarePen className="size-5" />
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||||
</button>
|
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||||
<button
|
aria-label="Go back"
|
||||||
type="button"
|
>
|
||||||
onClick={onOpenSearch}
|
<ChevronLeftIcon className="size-5" />
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
</button>
|
||||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
<button
|
||||||
aria-label="Search"
|
type="button"
|
||||||
>
|
onClick={onNavigateForward}
|
||||||
<SearchIcon className="size-5" />
|
disabled={!canNavigateForward}
|
||||||
</button>
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||||
{meetingAvailable && (
|
aria-label="Go forward"
|
||||||
<Tooltip>
|
>
|
||||||
<TooltipTrigger asChild>
|
<ChevronRightIcon className="size-5" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</>
|
||||||
onClick={onToggleMeeting}
|
|
||||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
|
||||||
className={cn(
|
|
||||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:pointer-events-none",
|
|
||||||
meetingSummarizing
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: meetingState === 'recording'
|
|
||||||
? "text-red-500 hover:bg-accent"
|
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
)}
|
|
||||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
|
||||||
>
|
|
||||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : meetingState === 'recording' ? (
|
|
||||||
<SquareIcon className="size-4 animate-pulse" />
|
|
||||||
) : (
|
|
||||||
<RadioIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
{meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -605,7 +637,9 @@ function App() {
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||||
|
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
|
||||||
|
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null)
|
||||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|
@ -900,6 +934,7 @@ function App() {
|
||||||
|
|
||||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||||
|
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
|
||||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||||
|
|
@ -2095,6 +2130,34 @@ function App() {
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [handleRunEvent])
|
}, [handleRunEvent])
|
||||||
|
|
||||||
|
type MiddlePaneContextPayload =
|
||||||
|
| { kind: 'note'; path: string; content: string }
|
||||||
|
| { kind: 'browser'; url: string; title: string }
|
||||||
|
const buildMiddlePaneContext = async (): Promise<MiddlePaneContextPayload | undefined> => {
|
||||||
|
// Nothing visible in the middle pane when the right pane is maximized.
|
||||||
|
if (isRightPaneMaximized) return undefined
|
||||||
|
|
||||||
|
// Browser is an overlay on top of any note — when it's open, it's what the user is looking at.
|
||||||
|
if (isBrowserOpen) {
|
||||||
|
try {
|
||||||
|
const state = await window.ipc.invoke('browser:getState', null)
|
||||||
|
const activeTab = state.tabs.find((t) => t.id === state.activeTabId)
|
||||||
|
if (activeTab) {
|
||||||
|
return { kind: 'browser', url: activeTab.url, title: activeTab.title }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to no-context if browser state is unavailable
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note case: only markdown files are meaningfully readable as context.
|
||||||
|
const path = selectedPathRef.current
|
||||||
|
if (!path || !path.endsWith('.md')) return undefined
|
||||||
|
const content = editorContentRef.current ?? ''
|
||||||
|
return { kind: 'note', path, content }
|
||||||
|
}
|
||||||
|
|
||||||
const handlePromptSubmit = async (
|
const handlePromptSubmit = async (
|
||||||
message: PromptInputMessage,
|
message: PromptInputMessage,
|
||||||
mentions?: FileMention[],
|
mentions?: FileMention[],
|
||||||
|
|
@ -2198,12 +2261,14 @@ function App() {
|
||||||
|
|
||||||
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
|
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
|
||||||
const attachmentPayload = contentParts as unknown as string
|
const attachmentPayload = contentParts as unknown as string
|
||||||
|
const middlePaneContext = await buildMiddlePaneContext()
|
||||||
await window.ipc.invoke('runs:createMessage', {
|
await window.ipc.invoke('runs:createMessage', {
|
||||||
runId: currentRunId,
|
runId: currentRunId,
|
||||||
message: attachmentPayload,
|
message: attachmentPayload,
|
||||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||||
searchEnabled: searchEnabled || undefined,
|
searchEnabled: searchEnabled || undefined,
|
||||||
|
middlePaneContext,
|
||||||
})
|
})
|
||||||
analytics.chatMessageSent({
|
analytics.chatMessageSent({
|
||||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||||
|
|
@ -2211,12 +2276,14 @@ function App() {
|
||||||
searchEnabled: searchEnabled || undefined,
|
searchEnabled: searchEnabled || undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
const middlePaneContext = await buildMiddlePaneContext()
|
||||||
await window.ipc.invoke('runs:createMessage', {
|
await window.ipc.invoke('runs:createMessage', {
|
||||||
runId: currentRunId,
|
runId: currentRunId,
|
||||||
message: userMessage,
|
message: userMessage,
|
||||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||||
searchEnabled: searchEnabled || undefined,
|
searchEnabled: searchEnabled || undefined,
|
||||||
|
middlePaneContext,
|
||||||
})
|
})
|
||||||
analytics.chatMessageSent({
|
analytics.chatMessageSent({
|
||||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||||
|
|
@ -2563,9 +2630,17 @@ function App() {
|
||||||
if (isGraphTabPath(tab.path)) {
|
if (isGraphTabPath(tab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
setSelectedPath(tab.path)
|
setSelectedPath(tab.path)
|
||||||
}, [fileTabs, isRightPaneMaximized])
|
}, [fileTabs, isRightPaneMaximized])
|
||||||
|
|
||||||
|
|
@ -2593,6 +2668,7 @@ function App() {
|
||||||
setActiveFileTabId(null)
|
setActiveFileTabId(null)
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const idx = prev.findIndex(t => t.id === tabId)
|
const idx = prev.findIndex(t => t.id === tabId)
|
||||||
|
|
@ -2605,8 +2681,14 @@ function App() {
|
||||||
if (isGraphTabPath(newActiveTab.path)) {
|
if (isGraphTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(true)
|
||||||
} else {
|
} else {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
setSelectedPath(newActiveTab.path)
|
setSelectedPath(newActiveTab.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2636,15 +2718,16 @@ function App() {
|
||||||
}
|
}
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
// Left-pane "new chat" should always open full chat view.
|
// Left-pane "new chat" should always open full chat view.
|
||||||
if (selectedPath || isGraphOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
|
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||||
} else {
|
} else {
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
}
|
}
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||||
|
|
||||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||||
const handleNewChatTabInSidebar = useCallback(() => {
|
const handleNewChatTabInSidebar = useCallback(() => {
|
||||||
|
|
@ -2687,11 +2770,71 @@ function App() {
|
||||||
setPendingPaletteSubmit(null)
|
setPendingPaletteSubmit(null)
|
||||||
}, [pendingPaletteSubmit])
|
}, [pendingPaletteSubmit])
|
||||||
|
|
||||||
|
// Listener for track-block "Edit with Copilot" events
|
||||||
|
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const ev = e as CustomEvent<{
|
||||||
|
trackId?: string
|
||||||
|
filePath?: string
|
||||||
|
}>
|
||||||
|
const trackId = ev.detail?.trackId
|
||||||
|
const filePath = ev.detail?.filePath
|
||||||
|
if (!trackId || !filePath) return
|
||||||
|
const displayName = filePath.split('/').pop() ?? filePath
|
||||||
|
submitFromPalette(
|
||||||
|
`Let's work on the \`${trackId}\` track in this note. Please load the \`tracks\` skill first, then ask me what I want to change.`,
|
||||||
|
{ path: filePath, displayName },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||||
|
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||||
|
}, [submitFromPalette])
|
||||||
|
|
||||||
|
// Listener for prompt-block "Run" events
|
||||||
|
// (dispatched by apps/renderer/src/extensions/prompt-block.tsx)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const ev = e as CustomEvent<{
|
||||||
|
instruction?: string
|
||||||
|
filePath?: string
|
||||||
|
label?: string
|
||||||
|
}>
|
||||||
|
const instruction = ev.detail?.instruction
|
||||||
|
const filePath = ev.detail?.filePath
|
||||||
|
if (!instruction) return
|
||||||
|
const mention = filePath
|
||||||
|
? { path: filePath, displayName: filePath.split('/').pop() ?? filePath }
|
||||||
|
: null
|
||||||
|
submitFromPalette(instruction, mention)
|
||||||
|
}
|
||||||
|
window.addEventListener('rowboat:open-copilot-prompt', handler as EventListener)
|
||||||
|
return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener)
|
||||||
|
}, [submitFromPalette])
|
||||||
|
|
||||||
const toggleKnowledgePane = useCallback(() => {
|
const toggleKnowledgePane = useCallback(() => {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setIsChatSidebarOpen(prev => !prev)
|
setIsChatSidebarOpen(prev => !prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Browser is an overlay on the middle pane: opening it forces the chat
|
||||||
|
// sidebar to be visible on the right; closing it restores whatever the
|
||||||
|
// middle pane was showing previously (file/graph/task/chat).
|
||||||
|
const handleToggleBrowser = useCallback(() => {
|
||||||
|
setIsBrowserOpen(prev => {
|
||||||
|
const next = !prev
|
||||||
|
if (next) {
|
||||||
|
setIsChatSidebarOpen(true)
|
||||||
|
setIsRightPaneMaximized(false)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCloseBrowser = useCallback(() => {
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleRightPaneMaximize = useCallback(() => {
|
const toggleRightPaneMaximize = useCallback(() => {
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
setIsRightPaneMaximized(prev => !prev)
|
setIsRightPaneMaximized(prev => !prev)
|
||||||
|
|
@ -2699,19 +2842,26 @@ function App() {
|
||||||
|
|
||||||
const handleOpenFullScreenChat = useCallback(() => {
|
const handleOpenFullScreenChat = useCallback(() => {
|
||||||
// Remember where we came from so the close button can return
|
// Remember where we came from so the close button can return
|
||||||
if (selectedPath || isGraphOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
|
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||||
}
|
}
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
}, [selectedPath, isGraphOpen])
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||||
|
|
||||||
const handleCloseFullScreenChat = useCallback(() => {
|
const handleCloseFullScreenChat = useCallback(() => {
|
||||||
if (expandedFrom) {
|
if (expandedFrom) {
|
||||||
if (expandedFrom.graph) {
|
if (expandedFrom.graph) {
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
} else if (expandedFrom.suggestedTopics) {
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(true)
|
||||||
} else if (expandedFrom.path) {
|
} else if (expandedFrom.path) {
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
setSelectedPath(expandedFrom.path)
|
setSelectedPath(expandedFrom.path)
|
||||||
}
|
}
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
|
|
@ -2721,10 +2871,11 @@ function App() {
|
||||||
|
|
||||||
const currentViewState = React.useMemo<ViewState>(() => {
|
const currentViewState = React.useMemo<ViewState>(() => {
|
||||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||||
|
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||||
if (isGraphOpen) return { type: 'graph' }
|
if (isGraphOpen) return { type: 'graph' }
|
||||||
return { type: 'chat', runId }
|
return { type: 'chat', runId }
|
||||||
}, [selectedBackgroundTask, selectedPath, isGraphOpen, runId])
|
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||||
|
|
||||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||||
const last = stack[stack.length - 1]
|
const last = stack[stack.length - 1]
|
||||||
|
|
@ -2770,11 +2921,26 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
}, [fileTabs])
|
}, [fileTabs])
|
||||||
|
|
||||||
|
const ensureSuggestedTopicsFileTab = useCallback(() => {
|
||||||
|
const existing = fileTabs.find((tab) => isSuggestedTopicsTabPath(tab.path))
|
||||||
|
if (existing) {
|
||||||
|
setActiveFileTabId(existing.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = newFileTabId()
|
||||||
|
setFileTabs((prev) => [...prev, { id, path: SUGGESTED_TOPICS_TAB_PATH }])
|
||||||
|
setActiveFileTabId(id)
|
||||||
|
}, [fileTabs])
|
||||||
|
|
||||||
const applyViewState = useCallback(async (view: ViewState) => {
|
const applyViewState = useCallback(async (view: ViewState) => {
|
||||||
switch (view.type) {
|
switch (view.type) {
|
||||||
case 'file':
|
case 'file':
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
// Navigating to a file dismisses the browser overlay so the file is
|
||||||
|
// visible in the middle pane.
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||||
// Only exit chat-only maximize, because that would hide the selected file.
|
// Only exit chat-only maximize, because that would hide the selected file.
|
||||||
|
|
@ -2787,6 +2953,8 @@ function App() {
|
||||||
case 'graph':
|
case 'graph':
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
ensureGraphFileTab()
|
ensureGraphFileTab()
|
||||||
|
|
@ -2797,16 +2965,31 @@ function App() {
|
||||||
case 'task':
|
case 'task':
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(view.name)
|
setSelectedBackgroundTask(view.name)
|
||||||
return
|
return
|
||||||
case 'chat':
|
case 'suggested-topics':
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsBrowserOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
ensureSuggestedTopicsFileTab()
|
||||||
|
return
|
||||||
|
case 'chat':
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
// Don't touch isBrowserOpen here — chat navigation should land in
|
||||||
|
// the right sidebar when the browser overlay is active.
|
||||||
|
setExpandedFrom(null)
|
||||||
|
setIsRightPaneMaximized(false)
|
||||||
|
setSelectedBackgroundTask(null)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
if (view.runId) {
|
if (view.runId) {
|
||||||
await loadRun(view.runId)
|
await loadRun(view.runId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2814,7 +2997,7 @@ function App() {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||||
|
|
||||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||||
const current = currentViewState
|
const current = currentViewState
|
||||||
|
|
@ -3079,7 +3262,7 @@ function App() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask
|
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||||
|
|
@ -3157,12 +3340,16 @@ function App() {
|
||||||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||||
const mod = e.metaKey || e.ctrlKey
|
const mod = e.metaKey || e.ctrlKey
|
||||||
if (!mod) return
|
if (!mod) return
|
||||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
|
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
|
||||||
const targetPane: ShortcutPane = rightPaneAvailable
|
const targetPane: ShortcutPane = rightPaneAvailable
|
||||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||||
: 'left'
|
: 'left'
|
||||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
|
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
|
||||||
const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
|
const selectedKnowledgePath = isGraphOpen
|
||||||
|
? GRAPH_TAB_PATH
|
||||||
|
: isSuggestedTopicsOpen
|
||||||
|
? SUGGESTED_TOPICS_TAB_PATH
|
||||||
|
: selectedPath
|
||||||
const targetFileTabId = activeFileTabId ?? (
|
const targetFileTabId = activeFileTabId ?? (
|
||||||
selectedKnowledgePath
|
selectedKnowledgePath
|
||||||
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
|
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
|
||||||
|
|
@ -3216,7 +3403,7 @@ function App() {
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleTabKeyDown)
|
document.addEventListener('keydown', handleTabKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||||
}, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||||
|
|
||||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
|
|
@ -3224,9 +3411,9 @@ function App() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level knowledge folders (except Notes) open as a bases view with folder filter
|
// Top-level knowledge folders open as a bases view with folder filter
|
||||||
const parts = path.split('/')
|
const parts = path.split('/')
|
||||||
if (parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes') {
|
if (parts.length === 2 && parts[0] === 'knowledge') {
|
||||||
const folderName = parts[1]
|
const folderName = parts[1]
|
||||||
const folderCfg = FOLDER_BASE_CONFIGS[folderName]
|
const folderCfg = FOLDER_BASE_CONFIGS[folderName]
|
||||||
setBaseConfigByPath((prev) => ({
|
setBaseConfigByPath((prev) => ({
|
||||||
|
|
@ -3241,7 +3428,7 @@ function App() {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -3363,14 +3550,14 @@ function App() {
|
||||||
},
|
},
|
||||||
openGraph: () => {
|
openGraph: () => {
|
||||||
// From chat-only landing state, open graph directly in full knowledge view.
|
// From chat-only landing state, open graph directly in full knowledge view.
|
||||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
void navigateToView({ type: 'graph' })
|
void navigateToView({ type: 'graph' })
|
||||||
},
|
},
|
||||||
openBases: () => {
|
openBases: () => {
|
||||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -3942,7 +4129,7 @@ function App() {
|
||||||
const selectedTask = selectedBackgroundTask
|
const selectedTask = selectedBackgroundTask
|
||||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||||
: null
|
: null
|
||||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
|
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
|
||||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||||
const openMarkdownTabs = React.useMemo(() => {
|
const openMarkdownTabs = React.useMemo(() => {
|
||||||
|
|
@ -3975,6 +4162,14 @@ function App() {
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
onSelectFile={toggleExpand}
|
onSelectFile={toggleExpand}
|
||||||
|
onToggleFolder={(path) => {
|
||||||
|
setExpandedPaths((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(path)) next.delete(path)
|
||||||
|
else next.add(path)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
knowledgeActions={knowledgeActions}
|
knowledgeActions={knowledgeActions}
|
||||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||||
runs={runs}
|
runs={runs}
|
||||||
|
|
@ -3984,7 +4179,7 @@ function App() {
|
||||||
onNewChat: handleNewChatTab,
|
onNewChat: handleNewChatTab,
|
||||||
onSelectRun: (runIdToLoad) => {
|
onSelectRun: (runIdToLoad) => {
|
||||||
cancelRecordingIfActive()
|
cancelRecordingIfActive()
|
||||||
if (selectedPath || isGraphOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3994,8 +4189,8 @@ function App() {
|
||||||
switchChatTab(existingTab.id)
|
switchChatTab(existingTab.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In two-pane mode, keep current knowledge/graph context and just swap chat context.
|
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||||
if (selectedPath || isGraphOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||||
loadRun(runIdToLoad)
|
loadRun(runIdToLoad)
|
||||||
return
|
return
|
||||||
|
|
@ -4019,14 +4214,14 @@ function App() {
|
||||||
} else {
|
} else {
|
||||||
// Only one tab, reset it to new chat
|
// Only one tab, reset it to new chat
|
||||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||||
if (selectedPath || isGraphOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
void navigateToView({ type: 'chat', runId: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (runId === runIdToDelete) {
|
} else if (runId === runIdToDelete) {
|
||||||
if (selectedPath || isGraphOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4044,6 +4239,16 @@ function App() {
|
||||||
}}
|
}}
|
||||||
backgroundTasks={backgroundTasks}
|
backgroundTasks={backgroundTasks}
|
||||||
selectedBackgroundTask={selectedBackgroundTask}
|
selectedBackgroundTask={selectedBackgroundTask}
|
||||||
|
onNewChat={handleNewChatTab}
|
||||||
|
onOpenSearch={() => setIsSearchOpen(true)}
|
||||||
|
meetingState={meetingTranscription.state}
|
||||||
|
meetingSummarizing={meetingSummarizing}
|
||||||
|
meetingAvailable={voiceAvailable}
|
||||||
|
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||||
|
isBrowserOpen={isBrowserOpen}
|
||||||
|
onToggleBrowser={handleToggleBrowser}
|
||||||
|
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||||
|
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||||
/>
|
/>
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -4063,7 +4268,7 @@ function App() {
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
>
|
>
|
||||||
{(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={fileTabs}
|
tabs={fileTabs}
|
||||||
activeTabId={activeFileTabId ?? ''}
|
activeTabId={activeFileTabId ?? ''}
|
||||||
|
|
@ -4071,7 +4276,7 @@ function App() {
|
||||||
getTabId={(t) => t.id}
|
getTabId={(t) => t.id}
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
@ -4124,7 +4329,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !selectedTask && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4139,7 +4344,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && expandedFrom && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4154,7 +4359,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(selectedPath || isGraphOpen) && (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4173,7 +4378,18 @@ function App() {
|
||||||
)}
|
)}
|
||||||
</ContentHeader>
|
</ContentHeader>
|
||||||
|
|
||||||
{selectedPath && isBaseFilePath(selectedPath) ? (
|
{isBrowserOpen ? (
|
||||||
|
<BrowserPane onClose={handleCloseBrowser} />
|
||||||
|
) : isSuggestedTopicsOpen ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
<SuggestedTopicsView
|
||||||
|
onExploreTopic={(topic) => {
|
||||||
|
const prompt = buildSuggestedTopicExplorePrompt(topic)
|
||||||
|
submitFromPalette(prompt, null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<BasesView
|
<BasesView
|
||||||
tree={tree}
|
tree={tree}
|
||||||
|
|
@ -4540,12 +4756,10 @@ function App() {
|
||||||
)}
|
)}
|
||||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||||
<FixedSidebarToggle
|
<FixedSidebarToggle
|
||||||
onNewChat={handleNewChatTab}
|
onNavigateBack={() => { void navigateBack() }}
|
||||||
onOpenSearch={() => setIsSearchOpen(true)}
|
onNavigateForward={() => { void navigateForward() }}
|
||||||
meetingState={meetingTranscription.state}
|
canNavigateBack={canNavigateBack}
|
||||||
meetingSummarizing={meetingSummarizing}
|
canNavigateForward={canNavigateForward}
|
||||||
meetingAvailable={voiceAvailable}
|
|
||||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
|
||||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
@ -4560,6 +4774,7 @@ function App() {
|
||||||
/>
|
/>
|
||||||
</SidebarSectionProvider>
|
</SidebarSectionProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<TrackModal />
|
||||||
<OnboardingModal
|
<OnboardingModal
|
||||||
open={showOnboarding}
|
open={showOnboarding}
|
||||||
onComplete={handleOnboardingComplete}
|
onComplete={handleOnboardingComplete}
|
||||||
|
|
|
||||||
|
|
@ -98,24 +98,33 @@ export const ToolHeader = ({
|
||||||
type,
|
type,
|
||||||
state,
|
state,
|
||||||
...props
|
...props
|
||||||
}: ToolHeaderProps) => (
|
}: ToolHeaderProps) => {
|
||||||
<CollapsibleTrigger
|
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between gap-4 p-3",
|
return (
|
||||||
className
|
<CollapsibleTrigger
|
||||||
)}
|
className={cn(
|
||||||
{...props}
|
"flex w-full items-center justify-between gap-4 p-3",
|
||||||
>
|
className
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
{...props}
|
||||||
<span className="font-medium text-sm">
|
>
|
||||||
{title ?? type.split("-").slice(1).join("-")}
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
</span>
|
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
{getStatusBadge(state)}
|
<span
|
||||||
</div>
|
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
title={displayTitle}
|
||||||
</CollapsibleTrigger>
|
>
|
||||||
);
|
{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>;
|
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||||
|
|
||||||
|
|
@ -215,4 +224,3 @@ export const ToolTabbedContent = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -76,12 +76,18 @@ function matchBillingError(message: string) {
|
||||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BillingRowboatAccount {
|
||||||
|
config?: {
|
||||||
|
appUrl?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
function BillingErrorCTA({ label }: { label: string }) {
|
function BillingErrorCTA({ label }: { label: string }) {
|
||||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.ipc.invoke('account:getRowboat', null)
|
window.ipc.invoke('account:getRowboat', null)
|
||||||
.then((account: any) => setAppUrl(account.config?.appUrl ?? null))
|
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -467,6 +473,7 @@ export function ChatSidebar({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={paneRef}
|
ref={paneRef}
|
||||||
|
data-chat-sidebar-root
|
||||||
onMouseDownCapture={onActivate}
|
onMouseDownCapture={onActivate}
|
||||||
onFocusCapture={onActivate}
|
onFocusCapture={onActivate}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,16 @@ import Image from '@tiptap/extension-image'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import TaskList from '@tiptap/extension-task-list'
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
import TaskItem from '@tiptap/extension-task-item'
|
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 { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
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 { ImageBlockExtension } from '@/extensions/image-block'
|
||||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||||
|
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
||||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||||
import { TableBlockExtension } from '@/extensions/table-block'
|
import { TableBlockExtension } from '@/extensions/table-block'
|
||||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||||
|
|
@ -42,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
|
// Post-process to clean up any zero-width spaces in the output
|
||||||
function postprocessMarkdown(markdown: string): string {
|
function postprocessMarkdown(markdown: string): string {
|
||||||
// Remove lines that contain only the zero-width space marker
|
// Remove lines that contain only the zero-width space marker
|
||||||
|
|
@ -121,6 +157,17 @@ function serializeList(listNode: JsonNode, indent: number): string[] {
|
||||||
return lines
|
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
|
// 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.
|
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
|
||||||
function blockToMarkdown(node: JsonNode): string {
|
function blockToMarkdown(node: JsonNode): string {
|
||||||
|
|
@ -140,10 +187,20 @@ function blockToMarkdown(node: JsonNode): string {
|
||||||
return serializeList(node, 0).join('\n')
|
return serializeList(node, 0).join('\n')
|
||||||
case 'taskBlock':
|
case 'taskBlock':
|
||||||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
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':
|
case 'imageBlock':
|
||||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||||
case 'embedBlock':
|
case 'embedBlock':
|
||||||
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||||
|
case 'iframeBlock':
|
||||||
|
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||||
case 'chartBlock':
|
case 'chartBlock':
|
||||||
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||||
case 'tableBlock':
|
case 'tableBlock':
|
||||||
|
|
@ -156,6 +213,8 @@ function blockToMarkdown(node: JsonNode): string {
|
||||||
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
|
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||||
case 'mermaidBlock':
|
case 'mermaidBlock':
|
||||||
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
|
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
|
||||||
|
case 'table':
|
||||||
|
return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim()
|
||||||
case 'codeBlock': {
|
case 'codeBlock': {
|
||||||
const lang = (node.attrs?.language as string) || ''
|
const lang = (node.attrs?.language as string) || ''
|
||||||
return '```' + lang + '\n' + nodeToText(node) + '\n```'
|
return '```' + lang + '\n' + nodeToText(node) + '\n```'
|
||||||
|
|
@ -638,8 +697,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
}),
|
}),
|
||||||
ImageUploadPlaceholderExtension,
|
ImageUploadPlaceholderExtension,
|
||||||
TaskBlockExtension,
|
TaskBlockExtension,
|
||||||
|
TrackBlockExtension.configure({ notePath }),
|
||||||
|
PromptBlockExtension.configure({ notePath }),
|
||||||
|
TrackTargetOpenExtension,
|
||||||
|
TrackTargetCloseExtension,
|
||||||
ImageBlockExtension,
|
ImageBlockExtension,
|
||||||
EmbedBlockExtension,
|
EmbedBlockExtension,
|
||||||
|
IframeBlockExtension,
|
||||||
ChartBlockExtension,
|
ChartBlockExtension,
|
||||||
TableBlockExtension,
|
TableBlockExtension,
|
||||||
CalendarBlockExtension,
|
CalendarBlockExtension,
|
||||||
|
|
@ -657,6 +721,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
|
TableKit.configure({
|
||||||
|
table: { resizable: false },
|
||||||
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1032,8 +1099,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||||
isInternalUpdate.current = true
|
isInternalUpdate.current = true
|
||||||
// Pre-process to preserve blank lines
|
// Pre-process to preserve blank lines, then wrap track-target comment
|
||||||
const preprocessed = preprocessMarkdown(content)
|
// 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.
|
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||||
isInternalUpdate.current = false
|
isInternalUpdate.current = false
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,18 @@ import {
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
Globe,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Mic,
|
Mic,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Radio,
|
||||||
|
SearchIcon,
|
||||||
|
SquarePen,
|
||||||
Table2,
|
Table2,
|
||||||
Plug,
|
Plug,
|
||||||
|
Lightbulb,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Settings,
|
Settings,
|
||||||
Square,
|
Square,
|
||||||
|
|
@ -58,6 +63,7 @@ import {
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
|
|
@ -90,6 +96,7 @@ import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { toast } from "@/lib/toast"
|
import { toast } from "@/lib/toast"
|
||||||
import { useBilling } from "@/hooks/useBilling"
|
import { useBilling } from "@/hooks/useBilling"
|
||||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||||
|
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
|
|
@ -164,6 +171,7 @@ type SidebarContentPanelProps = {
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
expandedPaths: Set<string>
|
||||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||||
|
onToggleFolder?: (path: string) => void
|
||||||
knowledgeActions: KnowledgeActions
|
knowledgeActions: KnowledgeActions
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
runs?: RunListItem[]
|
runs?: RunListItem[]
|
||||||
|
|
@ -172,6 +180,16 @@ type SidebarContentPanelProps = {
|
||||||
tasksActions?: TasksActions
|
tasksActions?: TasksActions
|
||||||
backgroundTasks?: BackgroundTaskItem[]
|
backgroundTasks?: BackgroundTaskItem[]
|
||||||
selectedBackgroundTask?: string | null
|
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>
|
} & React.ComponentProps<typeof Sidebar>
|
||||||
|
|
||||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||||
|
|
@ -387,6 +405,7 @@ export function SidebarContentPanel({
|
||||||
selectedPath,
|
selectedPath,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
|
onToggleFolder,
|
||||||
knowledgeActions,
|
knowledgeActions,
|
||||||
onVoiceNoteCreated,
|
onVoiceNoteCreated,
|
||||||
runs = [],
|
runs = [],
|
||||||
|
|
@ -395,6 +414,16 @@ export function SidebarContentPanel({
|
||||||
tasksActions,
|
tasksActions,
|
||||||
backgroundTasks = [],
|
backgroundTasks = [],
|
||||||
selectedBackgroundTask,
|
selectedBackgroundTask,
|
||||||
|
onNewChat,
|
||||||
|
onOpenSearch,
|
||||||
|
meetingState = 'idle',
|
||||||
|
meetingSummarizing = false,
|
||||||
|
meetingAvailable = false,
|
||||||
|
onToggleMeeting,
|
||||||
|
isBrowserOpen = false,
|
||||||
|
onToggleBrowser,
|
||||||
|
isSuggestedTopicsOpen = false,
|
||||||
|
onOpenSuggestedTopics,
|
||||||
...props
|
...props
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
const { activeSection, setActiveSection } = useSidebarSection()
|
const { activeSection, setActiveSection } = useSidebarSection()
|
||||||
|
|
@ -488,6 +517,89 @@ export function SidebarContentPanel({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{activeSection === "knowledge" && (
|
{activeSection === "knowledge" && (
|
||||||
|
|
@ -496,6 +608,7 @@ export function SidebarContentPanel({
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
actions={knowledgeActions}
|
actions={knowledgeActions}
|
||||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
onVoiceNoteCreated={onVoiceNoteCreated}
|
||||||
/>
|
/>
|
||||||
|
|
@ -884,6 +997,7 @@ function KnowledgeSection({
|
||||||
selectedPath,
|
selectedPath,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
|
onToggleFolder,
|
||||||
actions,
|
actions,
|
||||||
onVoiceNoteCreated,
|
onVoiceNoteCreated,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -891,6 +1005,7 @@ function KnowledgeSection({
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
expandedPaths: Set<string>
|
||||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||||
|
onToggleFolder?: (path: string) => void
|
||||||
actions: KnowledgeActions
|
actions: KnowledgeActions
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -980,6 +1095,7 @@ function KnowledgeSection({
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
onSelect={onSelectFile}
|
onSelect={onSelectFile}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1008,9 +1124,7 @@ function countFiles(node: TreeNode): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display name overrides for top-level knowledge folders */
|
/** Display name overrides for top-level knowledge folders */
|
||||||
const FOLDER_DISPLAY_NAMES: Record<string, string> = {
|
const FOLDER_DISPLAY_NAMES: Record<string, string> = {}
|
||||||
Notes: 'My Notes',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tree component for file browser
|
// Tree component for file browser
|
||||||
function Tree({
|
function Tree({
|
||||||
|
|
@ -1018,12 +1132,14 @@ function Tree({
|
||||||
selectedPath,
|
selectedPath,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggleFolder,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
item: TreeNode
|
item: TreeNode
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
expandedPaths: Set<string>
|
||||||
onSelect: (path: string, kind: "file" | "dir") => void
|
onSelect: (path: string, kind: "file" | "dir") => void
|
||||||
|
onToggleFolder?: (path: string) => void
|
||||||
actions: KnowledgeActions
|
actions: KnowledgeActions
|
||||||
}) {
|
}) {
|
||||||
const isDir = item.kind === 'dir'
|
const isDir = item.kind === 'dir'
|
||||||
|
|
@ -1160,15 +1276,15 @@ function Tree({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level knowledge folders (except Notes) open bases view — render as flat items
|
// Top-level knowledge folders open bases view — render as flat items
|
||||||
const parts = item.path.split('/')
|
const parts = item.path.split('/')
|
||||||
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes'
|
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge'
|
||||||
|
|
||||||
if (isBasesFolder) {
|
if (isBasesFolder) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem className="group/file-item">
|
||||||
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
|
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
|
||||||
<Folder className="size-4 shrink-0" />
|
<Folder className="size-4 shrink-0" />
|
||||||
<div className="flex w-full items-center gap-1 min-w-0">
|
<div className="flex w-full items-center gap-1 min-w-0">
|
||||||
|
|
@ -1176,6 +1292,38 @@ function Tree({
|
||||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</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>
|
</SidebarMenuItem>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
{contextMenuContent}
|
{contextMenuContent}
|
||||||
|
|
@ -1240,6 +1388,7 @@ function Tree({
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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\//, '')
|
||||||
|
}
|
||||||
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: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
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')
|
||||||
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: {
|
electronUtils: {
|
||||||
getPathForFile: (file: File) => string;
|
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);
|
||||||
|
}
|
||||||
|
|
@ -231,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.
|
// Parse attached files from message content and return clean message + file paths.
|
||||||
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||||
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||||
|
|
@ -315,6 +503,7 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
'web-search': 'Searching the web',
|
'web-search': 'Searching the web',
|
||||||
'save-to-memory': 'Saving to memory',
|
'save-to-memory': 'Saving to memory',
|
||||||
'app-navigation': 'Navigating app',
|
'app-navigation': 'Navigating app',
|
||||||
|
'browser-control': 'Controlling browser',
|
||||||
'composio-list-toolkits': 'Listing integrations',
|
'composio-list-toolkits': 'Listing integrations',
|
||||||
'composio-search-tools': 'Searching tools',
|
'composio-search-tools': 'Searching tools',
|
||||||
'composio-execute-tool': 'Running tool',
|
'composio-execute-tool': 'Running tool',
|
||||||
|
|
@ -328,6 +517,8 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
* Falls back to the raw tool name if no mapping exists.
|
* Falls back to the raw tool name if no mapping exists.
|
||||||
*/
|
*/
|
||||||
export const getToolDisplayName = (tool: ToolCall): string => {
|
export const getToolDisplayName = (tool: ToolCall): string => {
|
||||||
|
const browserLabel = getBrowserControlLabel(tool)
|
||||||
|
if (browserLabel) return browserLabel
|
||||||
const composioData = getComposioActionCardData(tool)
|
const composioData = getComposioActionCardData(tool)
|
||||||
if (composioData) return composioData.label
|
if (composioData) return composioData.label
|
||||||
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
|
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,48 @@
|
||||||
color: #eb5757;
|
color: #eb5757;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native GFM tables (distinct from the custom tableBlock above) */
|
||||||
|
.tiptap-editor .ProseMirror .tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror table th,
|
||||||
|
.tiptap-editor .ProseMirror table td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 10px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror table th {
|
||||||
|
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror table p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror table .selectedCell::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Divider */
|
/* Divider */
|
||||||
.tiptap-editor .ProseMirror hr {
|
.tiptap-editor .ProseMirror hr {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -611,10 +653,160 @@
|
||||||
.tiptap-editor .ProseMirror .task-block-last-run {
|
.tiptap-editor .ProseMirror .task-block-last-run {
|
||||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||||
}
|
}
|
||||||
|
/* =============================================================
|
||||||
|
Track Block — inline chip (display-only)
|
||||||
|
The chip just opens a modal (TrackModal). All mutations live in the
|
||||||
|
modal and go through IPC, so the editor never writes track state.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
|
||||||
|
--track-accent: #64748b; /* default: manual/slate */
|
||||||
|
margin: 4px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
|
||||||
|
border-left: 3px solid var(--track-accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip:hover {
|
||||||
|
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
|
||||||
|
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip:active {
|
||||||
|
transform: translateY(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
|
||||||
|
outline: 2px solid var(--track-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-running {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
|
||||||
|
animation: track-chip-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes track-chip-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--track-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--track-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||||
|
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||||
|
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||||
|
outline: 2px solid var(--track-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
Track target markers — thin visual bookends around a track's
|
||||||
|
output region. The content BETWEEN these markers is normal,
|
||||||
|
editable document content (rendered by the existing extensions).
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
|
||||||
|
position: relative;
|
||||||
|
height: 1px;
|
||||||
|
margin: 14px 0 6px 0;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
|
||||||
|
content: 'track: ' attr(data-track-id);
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--background, #fff);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||||
|
text-transform: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
|
||||||
|
height: 1px;
|
||||||
|
margin: 6px 0 14px 0;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
|
||||||
|
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared block styles (image, embed, chart, table) */
|
/* Shared block styles (image, embed, chart, table) */
|
||||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .table-block-wrapper,
|
.tiptap-editor .ProseMirror .table-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-wrapper,
|
.tiptap-editor .ProseMirror .calendar-block-wrapper,
|
||||||
|
|
@ -626,6 +818,7 @@
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-card,
|
.tiptap-editor .ProseMirror .image-block-card,
|
||||||
.tiptap-editor .ProseMirror .embed-block-card,
|
.tiptap-editor .ProseMirror .embed-block-card,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-card,
|
||||||
.tiptap-editor .ProseMirror .chart-block-card,
|
.tiptap-editor .ProseMirror .chart-block-card,
|
||||||
.tiptap-editor .ProseMirror .table-block-card,
|
.tiptap-editor .ProseMirror .table-block-card,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-card,
|
.tiptap-editor .ProseMirror .calendar-block-card,
|
||||||
|
|
@ -644,6 +837,7 @@
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .table-block-card:hover,
|
.tiptap-editor .ProseMirror .table-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-card:hover,
|
.tiptap-editor .ProseMirror .calendar-block-card:hover,
|
||||||
|
|
@ -656,6 +850,7 @@
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-wrapper.ProseMirror-selectednode .iframe-block-card,
|
||||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
|
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
|
||||||
|
|
@ -668,6 +863,7 @@
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-delete,
|
.tiptap-editor .ProseMirror .image-block-delete,
|
||||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-delete,
|
||||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||||
.tiptap-editor .ProseMirror .table-block-delete,
|
.tiptap-editor .ProseMirror .table-block-delete,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-delete,
|
.tiptap-editor .ProseMirror .calendar-block-delete,
|
||||||
|
|
@ -694,6 +890,7 @@
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-card:hover .iframe-block-delete,
|
||||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
|
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
|
||||||
|
|
@ -705,6 +902,7 @@
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
|
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
|
||||||
|
|
@ -794,6 +992,103 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Iframe block */
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-frame-shell {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.18s ease;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 14%, transparent), transparent 45%),
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--muted) 65%, transparent), color-mix(in srgb, var(--background) 95%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 10%, transparent), transparent 42%),
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--muted) 88%, transparent), color-mix(in srgb, var(--background) 98%, transparent));
|
||||||
|
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-loading-bar {
|
||||||
|
width: min(220px, 46%);
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--primary) 60%, transparent) 50%, transparent 100%),
|
||||||
|
color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
|
background-size: 180px 100%, auto;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
animation: iframe-block-loading-sweep 1.05s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-loading-copy {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-frame-shell-ready .iframe-block-loading-overlay {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-frame-shell-loading .iframe-block-frame {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .iframe-block-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iframe-block-loading-sweep {
|
||||||
|
from {
|
||||||
|
background-position: -180px 0, 0 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: calc(100% + 180px) 0, 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Chart block */
|
/* Chart block */
|
||||||
.tiptap-editor .ProseMirror .chart-block-title {
|
.tiptap-editor .ProseMirror .chart-block-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,8 @@ import { glob } from "node:fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { Agent } from "@x/shared/dist/agent.js";
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const UpdateAgentSchema = Agent.omit({ name: true });
|
const UpdateAgentSchema = Agent.omit({ name: true });
|
||||||
|
|
@ -33,7 +34,10 @@ export class FSAgentsRepo implements IAgentsRepo {
|
||||||
for (const file of matches) {
|
for (const file of matches) {
|
||||||
try {
|
try {
|
||||||
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
|
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
|
||||||
result.push(agent);
|
result.push({
|
||||||
|
...agent,
|
||||||
|
name: file.replace(/\.md$/, ""),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -42,44 +46,33 @@ export class FSAgentsRepo implements IAgentsRepo {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseAgentMd(filePath: string): Promise<z.infer<typeof Agent>> {
|
private async parseAgentMd(filepath: string): Promise<z.infer<typeof Agent>> {
|
||||||
const raw = await fs.readFile(filePath, "utf8");
|
const raw = await fs.readFile(filepath, "utf8");
|
||||||
|
|
||||||
// strip the path prefix from the file name
|
const { frontmatter, content } = parseFrontmatter(raw);
|
||||||
// and the .md extension
|
if (frontmatter) {
|
||||||
const agentName = filePath
|
const parsed = Agent
|
||||||
.replace(this.agentsDir + "/", "")
|
.omit({ instructions: true })
|
||||||
.replace(/\.md$/, "");
|
.parse(frontmatter);
|
||||||
let agent: z.infer<typeof Agent> = {
|
|
||||||
name: agentName,
|
|
||||||
instructions: raw,
|
|
||||||
};
|
|
||||||
let content = raw;
|
|
||||||
|
|
||||||
// check for frontmatter markers at start
|
return {
|
||||||
if (raw.startsWith("---")) {
|
...parsed,
|
||||||
const end = raw.indexOf("\n---", 3);
|
instructions: content,
|
||||||
|
};
|
||||||
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 agent;
|
return {
|
||||||
|
name: filepath,
|
||||||
|
instructions: raw,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(id: string): Promise<z.infer<typeof Agent>> {
|
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> {
|
async create(agent: z.infer<typeof Agent>): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { execTool } from "../application/lib/exec-tool.js";
|
||||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||||
import { buildCopilotAgent } from "../application/assistant/agent.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 { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
import { IModelConfigRepo } from "../models/repo.js";
|
import { IModelConfigRepo } from "../models/repo.js";
|
||||||
|
|
@ -372,6 +373,10 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
return buildCopilotAgent();
|
return buildCopilotAgent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id === "track-run") {
|
||||||
|
return buildTrackRunAgent();
|
||||||
|
}
|
||||||
|
|
||||||
if (id === 'note_creation') {
|
if (id === 'note_creation') {
|
||||||
const raw = getNoteCreationRaw();
|
const raw = getNoteCreationRaw();
|
||||||
let agent: z.infer<typeof Agent> = {
|
let agent: z.infer<typeof Agent> = {
|
||||||
|
|
@ -873,6 +878,10 @@ export async function* streamAgent({
|
||||||
let voiceInput = false;
|
let voiceInput = false;
|
||||||
let voiceOutput: 'summary' | 'full' | null = null;
|
let voiceOutput: 'summary' | 'full' | null = null;
|
||||||
let searchEnabled = false;
|
let searchEnabled = false;
|
||||||
|
let middlePaneContext:
|
||||||
|
| { kind: 'note'; path: string; content: string }
|
||||||
|
| { kind: 'browser'; url: string; title: string }
|
||||||
|
| null = null;
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check abort at the top of each iteration
|
// Check abort at the top of each iteration
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
|
|
@ -1000,6 +1009,9 @@ export async function* streamAgent({
|
||||||
if (msg.voiceOutput) {
|
if (msg.voiceOutput) {
|
||||||
voiceOutput = 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);
|
loopLogger.log('dequeued user message', msg.messageId);
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -1046,6 +1058,19 @@ export async function* streamAgent({
|
||||||
if (agentNotesContext) {
|
if (agentNotesContext) {
|
||||||
instructionsWithDateTime += `\n\n${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) {
|
if (voiceInput) {
|
||||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
loopLogger.log('voice input enabled, injecting voice input prompt');
|
||||||
|
|
|
||||||
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,4 +1,4 @@
|
||||||
import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal
|
import { skillCatalog, buildSkillCatalog } from "./skills/index.js";
|
||||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||||
|
|
@ -12,15 +12,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
*/
|
*/
|
||||||
async function getComposioToolsPrompt(): Promise<string> {
|
async function getComposioToolsPrompt(): Promise<string> {
|
||||||
if (!(await isComposioConfigured())) {
|
if (!(await isComposioConfigured())) {
|
||||||
return `
|
return '';
|
||||||
## Composio Integrations
|
|
||||||
|
|
||||||
**Composio is not configured.** Composio enables integrations with third-party services like Google Sheets, GitHub, Slack, Jira, Notion, LinkedIn, and 20+ others.
|
|
||||||
|
|
||||||
When the user asks to interact with any third-party service (e.g., "connect to Google Sheets", "create a GitHub issue"), do NOT attempt to write code, use shell commands, or load the composio-integration skill. Instead, let the user know that these integrations are available through Composio, and they can enable them by adding their Composio API key in **Settings > Tools Library**. They can get their key from https://app.composio.dev/settings.
|
|
||||||
|
|
||||||
**Exception — Email and Calendar:** For email-related requests (reading emails, sending emails, drafting replies) or calendar-related requests (checking schedule, listing events), do NOT direct the user to Composio. Instead, tell them to connect their email and calendar in **Settings > Connected Accounts**.
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
|
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
|
||||||
|
|
@ -37,7 +29,29 @@ Load the \`composio-integration\` skill when the user asks to interact with any
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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.
|
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
|
||||||
|
|
||||||
|
|
@ -58,11 +72,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
|
||||||
## What Rowboat Is
|
## 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.
|
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** 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. Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.
|
**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}
|
||||||
|
|
||||||
**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.
|
${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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|
@ -70,6 +82,9 @@ 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.
|
**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.
|
||||||
|
|
||||||
|
**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)
|
## Learning About the User (save-to-memory)
|
||||||
|
|
||||||
|
|
@ -101,7 +116,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.
|
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
|
||||||
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
|
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
|
||||||
- **Organizations/** - Notes on companies and teams
|
- **Organizations/** - Notes on companies and teams
|
||||||
- **Projects/** - Notes on ongoing initiatives and workstreams
|
- **Projects/** - Notes on ongoing initiatives and workstreams
|
||||||
|
|
@ -175,7 +191,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.
|
- 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.
|
- Apply the instructions from every loaded skill while working on the request.
|
||||||
|
|
||||||
\${skillCatalog}
|
${catalog}
|
||||||
|
|
||||||
Always consult this catalog first so you load the right skills before taking action.
|
Always consult this catalog first so you load the right skills before taking action.
|
||||||
|
|
||||||
|
|
@ -202,7 +218,7 @@ Always consult this catalog first so you load the right skills before taking act
|
||||||
|
|
||||||
## Tool Priority
|
## Tool Priority
|
||||||
|
|
||||||
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.
|
${toolPriority}
|
||||||
|
|
||||||
## Execution Reminders
|
## Execution Reminders
|
||||||
- Explore existing files and structure before creating new assets.
|
- Explore existing files and structure before creating new assets.
|
||||||
|
|
@ -238,11 +254,11 @@ ${runtimeContextPrompt}
|
||||||
- \`analyzeAgent\` - Agent analysis
|
- \`analyzeAgent\` - Agent analysis
|
||||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||||
- \`loadSkill\` - Skill loading
|
- \`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.
|
${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\`.
|
||||||
- \`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.**
|
- \`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.
|
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||||
- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.
|
${composioToolsLine}
|
||||||
|
|
||||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, 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\`.
|
||||||
|
|
||||||
|
|
@ -279,9 +295,20 @@ 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.)
|
- Files on the user's machine (~/Desktop/..., /Users/..., etc.)
|
||||||
- Audio files, images, documents, or any file reference
|
- 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.
|
**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.`;
|
Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep backward-compatible export for any external consumers */
|
||||||
|
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
||||||
|
|
@ -302,9 +329,14 @@ export function invalidateCopilotInstructionsCache(): void {
|
||||||
*/
|
*/
|
||||||
export async function buildCopilotInstructions(): Promise<string> {
|
export async function buildCopilotInstructions(): Promise<string> {
|
||||||
if (cachedInstructions !== null) return cachedInstructions;
|
if (cachedInstructions !== null) return cachedInstructions;
|
||||||
|
const composioEnabled = await isComposioConfigured();
|
||||||
|
const catalog = composioEnabled
|
||||||
|
? skillCatalog
|
||||||
|
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
||||||
|
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
||||||
const composioPrompt = await getComposioToolsPrompt();
|
const composioPrompt = await getComposioToolsPrompt();
|
||||||
cachedInstructions = composioPrompt
|
cachedInstructions = composioPrompt
|
||||||
? CopilotInstructions + '\n' + composioPrompt
|
? baseInstructions + '\n' + composioPrompt
|
||||||
: CopilotInstructions;
|
: baseInstructions;
|
||||||
return cachedInstructions;
|
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;
|
||||||
|
|
@ -71,24 +71,24 @@ workspace-grep({ pattern: "[name]", path: "knowledge/" })
|
||||||
- Ask: "Which document would you like to work on?"
|
- Ask: "Which document would you like to work on?"
|
||||||
|
|
||||||
**Creating new documents:**
|
**Creating new documents:**
|
||||||
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/\` root)
|
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/Notes/\` unless the user specifies a different folder)
|
||||||
2. Create it with just a title - don't pre-populate with structure or outlines
|
2. Create it with just a title - don't pre-populate with structure or outlines
|
||||||
3. Ask: "What would you like in this?"
|
3. Ask: "What would you like in this?"
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
workspace-createFile({
|
workspace-createFile({
|
||||||
path: "knowledge/[Document Name].md",
|
path: "knowledge/Notes/[Document Name].md",
|
||||||
content: "# [Document Title]\n\n"
|
content: "# [Document Title]\n\n"
|
||||||
})
|
})
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**WRONG approach:**
|
**WRONG approach:**
|
||||||
- "Should this be in Projects/ or Topics/?" - don't ask, just use root
|
- "Should this be in Projects/ or Topics/?" - don't ask, just use \`knowledge/Notes/\`
|
||||||
- "Here's a proposed outline..." - don't propose, let the user guide
|
- "Here's a proposed outline..." - don't propose, let the user guide
|
||||||
- "I'll create a structure with sections for X, Y, Z" - don't assume structure
|
- "I'll create a structure with sections for X, Y, Z" - don't assume structure
|
||||||
|
|
||||||
**RIGHT approach:**
|
**RIGHT approach:**
|
||||||
- "Shall I create knowledge/roadmap.md?"
|
- "Shall I create knowledge/Notes/roadmap.md?"
|
||||||
- *creates file with just the title*
|
- *creates file with just the title*
|
||||||
- "Created. What would you like in this?"
|
- "Created. What would you like in this?"
|
||||||
|
|
||||||
|
|
@ -167,11 +167,11 @@ workspace-readFile("knowledge/Projects/[Project].md")
|
||||||
## Document Locations
|
## Document Locations
|
||||||
|
|
||||||
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
|
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
|
||||||
|
- \`Notes/\` - **Default location for user notes. Create new notes here unless the user specifies a different folder.**
|
||||||
- \`People/\` - Notes about individuals
|
- \`People/\` - Notes about individuals
|
||||||
- \`Organizations/\` - Notes about companies, teams
|
- \`Organizations/\` - Notes about companies, teams
|
||||||
- \`Projects/\` - Project documentation
|
- \`Projects/\` - Project documentation
|
||||||
- \`Topics/\` - Subject matter notes
|
- \`Topics/\` - Subject matter notes
|
||||||
- Root level for general documents
|
|
||||||
|
|
||||||
## Rich Blocks
|
## Rich Blocks
|
||||||
|
|
||||||
|
|
@ -196,6 +196,17 @@ Embeds external content (YouTube videos, Figma designs, or generic links).
|
||||||
- \`caption\` (optional): Caption displayed below the embed
|
- \`caption\` (optional): Caption displayed below the embed
|
||||||
- YouTube and Figma render as iframes; generic shows a link card
|
- YouTube and Figma render as iframes; generic shows a link card
|
||||||
|
|
||||||
|
### Iframe Block
|
||||||
|
Embeds an arbitrary web page or a locally-served dashboard in the note.
|
||||||
|
\`\`\`iframe
|
||||||
|
{"url": "http://localhost:3210/sites/example-dashboard/", "title": "Trend Dashboard", "height": 640}
|
||||||
|
\`\`\`
|
||||||
|
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or \`http://localhost:3210/sites/<slug>/\` for local dashboards
|
||||||
|
- \`title\` (optional): Title shown above the iframe
|
||||||
|
- \`height\` (optional): Height in pixels. Good dashboard defaults are 480-800
|
||||||
|
- \`allow\` (optional): Custom iframe \`allow\` attribute when the page needs extra browser capabilities
|
||||||
|
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in \`sites/<slug>/\` and use the localhost URL above
|
||||||
|
|
||||||
### Chart Block
|
### Chart Block
|
||||||
Renders a chart from inline data.
|
Renders a chart from inline data.
|
||||||
\`\`\`chart
|
\`\`\`chart
|
||||||
|
|
@ -220,8 +231,9 @@ Renders a styled table from structured data.
|
||||||
### Block Guidelines
|
### Block Guidelines
|
||||||
- The JSON must be valid and on a single line (no pretty-printing)
|
- The JSON must be valid and on a single line (no pretty-printing)
|
||||||
- Insert blocks using \`workspace-editFile\` just like any other content
|
- Insert blocks using \`workspace-editFile\` just like any other content
|
||||||
- When the user asks for a chart, table, or embed — use blocks rather than plain Markdown tables or image links
|
- When the user asks for a chart, table, embed, or live dashboard — use blocks rather than plain Markdown tables or image links
|
||||||
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
||||||
|
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||||
|
|
||||||
import appNavigationSkill from "./app-navigation/skill.js";
|
import appNavigationSkill from "./app-navigation/skill.js";
|
||||||
|
import browserControlSkill from "./browser-control/skill.js";
|
||||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||||
|
import tracksSkill from "./tracks/skill.js";
|
||||||
|
|
||||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||||
|
|
||||||
|
// console.log(tracksSkill);
|
||||||
|
|
||||||
type SkillDefinition = {
|
type SkillDefinition = {
|
||||||
id: string; // Also used as folder name
|
id: string; // Also used as folder name
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -96,6 +100,18 @@ const definitions: SkillDefinition[] = [
|
||||||
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
||||||
content: appNavigationSkill,
|
content: appNavigationSkill,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tracks",
|
||||||
|
title: "Tracks",
|
||||||
|
summary: "Create and manage track blocks — YAML-scheduled auto-updating content blocks in notes (weather, news, prices, status, dashboards). Insert at cursor (Cmd+K) or append to notes.",
|
||||||
|
content: tracksSkill,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "browser-control",
|
||||||
|
title: "Browser Control",
|
||||||
|
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
|
||||||
|
content: browserControlSkill,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const skillEntries = definitions.map((definition) => ({
|
const skillEntries = definitions.map((definition) => ({
|
||||||
|
|
@ -117,6 +133,27 @@ export const skillCatalog = [
|
||||||
catalogSections.join("\n\n"),
|
catalogSections.join("\n\n"),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a skill catalog string, optionally excluding specific skills by ID.
|
||||||
|
*/
|
||||||
|
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
||||||
|
const entries = options?.excludeIds
|
||||||
|
? skillEntries.filter(e => !options.excludeIds!.includes(e.id))
|
||||||
|
: skillEntries;
|
||||||
|
const sections = entries.map((entry) => [
|
||||||
|
`## ${entry.title}`,
|
||||||
|
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||||
|
`- **Use it for:** ${entry.summary}`,
|
||||||
|
].join("\n"));
|
||||||
|
return [
|
||||||
|
"# Rowboat Skill Catalog",
|
||||||
|
"",
|
||||||
|
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
|
||||||
|
"",
|
||||||
|
sections.join("\n\n"),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeIdentifier = (value: string) =>
|
const normalizeIdentifier = (value: string) =>
|
||||||
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
|
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { WorkDir } from "../../config/config.js";
|
||||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.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 { 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 type { ToolContext } from "./exec-tool.js";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { createProvider } from "../../models/models.js";
|
import { createProvider } from "../../models/models.js";
|
||||||
|
|
@ -25,6 +26,8 @@ import { isSignedIn } from "../../account/account.js";
|
||||||
import { getGatewayProvider } from "../../models/gateway.js";
|
import { getGatewayProvider } from "../../models/gateway.js";
|
||||||
import { getAccessToken } from "../../auth/tokens.js";
|
import { getAccessToken } from "../../auth/tokens.js";
|
||||||
import { API_URL } from "../../config/env.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()
|
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||||
// Import paths are computed so esbuild cannot statically resolve them.
|
// Import paths are computed so esbuild cannot statically resolve them.
|
||||||
|
|
@ -561,7 +564,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
count: matches.length,
|
count: matches.length,
|
||||||
tool: 'ripgrep',
|
tool: 'ripgrep',
|
||||||
};
|
};
|
||||||
} catch (rgError) {
|
} catch {
|
||||||
// Fallback to basic grep if ripgrep not available or failed
|
// Fallback to basic grep if ripgrep not available or failed
|
||||||
const grepArgs = [
|
const grepArgs = [
|
||||||
'-rn',
|
'-rn',
|
||||||
|
|
@ -996,6 +999,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
|
// App Navigation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -1431,4 +1467,56 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
},
|
},
|
||||||
isAvailable: async () => isComposioConfigured(),
|
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 UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||||
export type VoiceOutputMode = 'summary' | 'full';
|
export type VoiceOutputMode = 'summary' | 'full';
|
||||||
|
export type MiddlePaneContext =
|
||||||
|
| { kind: 'note'; path: string; content: string }
|
||||||
|
| { kind: 'browser'; url: string; title: string };
|
||||||
|
|
||||||
type EnqueuedMessage = {
|
type EnqueuedMessage = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|
@ -11,10 +14,11 @@ type EnqueuedMessage = {
|
||||||
voiceInput?: boolean;
|
voiceInput?: boolean;
|
||||||
voiceOutput?: VoiceOutputMode;
|
voiceOutput?: VoiceOutputMode;
|
||||||
searchEnabled?: boolean;
|
searchEnabled?: boolean;
|
||||||
|
middlePaneContext?: MiddlePaneContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IMessageQueue {
|
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>;
|
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,7 +34,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
||||||
this.idGenerator = idGenerator;
|
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]) {
|
if (!this.store[runId]) {
|
||||||
this.store[runId] = [];
|
this.store[runId] = [];
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +45,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
||||||
voiceInput,
|
voiceInput,
|
||||||
voiceOutput,
|
voiceOutput,
|
||||||
searchEnabled,
|
searchEnabled,
|
||||||
|
middlePaneContext,
|
||||||
});
|
});
|
||||||
return id;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -216,12 +216,15 @@ export async function refreshTokens(
|
||||||
return tokens;
|
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 {
|
export function isTokenExpired(tokens: OAuthTokens): boolean {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
return tokens.expires_at <= now;
|
return tokens.expires_at <= now + EXPIRY_MARGIN_SECONDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,12 @@ import { IOAuthRepo } from './repo.js';
|
||||||
import { IClientRegistrationRepo } from './client-repo.js';
|
import { IClientRegistrationRepo } from './client-repo.js';
|
||||||
import { getProviderConfig } from './providers.js';
|
import { getProviderConfig } from './providers.js';
|
||||||
import * as oauthClient from './oauth-client.js';
|
import * as oauthClient from './oauth-client.js';
|
||||||
|
import { OAuthTokens } from './types.js';
|
||||||
|
|
||||||
export async function getAccessToken(): Promise<string> {
|
let refreshInFlight: Promise<OAuthTokens> | null = null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function performRefresh(tokens: OAuthTokens): Promise<OAuthTokens> {
|
||||||
|
console.log("Refreshing rowboat access token");
|
||||||
if (!tokens.refresh_token) {
|
if (!tokens.refresh_token) {
|
||||||
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
|
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +34,29 @@ export async function getAccessToken(): Promise<string> {
|
||||||
tokens.refresh_token,
|
tokens.refresh_token,
|
||||||
tokens.scopes,
|
tokens.scopes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
await oauthRepo.upsert('rowboat', { tokens: refreshed });
|
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;
|
return refreshed.access_token;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { asClass, createContainer, InjectionMode } from "awilix";
|
import { asClass, asValue, createContainer, InjectionMode } from "awilix";
|
||||||
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
|
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
|
||||||
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
|
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
|
||||||
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
|
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
|
||||||
|
|
@ -15,6 +15,7 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js
|
||||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||||
|
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||||
|
|
||||||
const container = createContainer({
|
const container = createContainer({
|
||||||
injectionMode: InjectionMode.PROXY,
|
injectionMode: InjectionMode.PROXY,
|
||||||
|
|
@ -41,4 +42,10 @@ container.register({
|
||||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default container;
|
export default container;
|
||||||
|
|
||||||
|
export function registerBrowserControlService(service: IBrowserControlService): void {
|
||||||
|
container.register({
|
||||||
|
browserControlService: asValue(service),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import path from 'path';
|
||||||
import { google } from 'googleapis';
|
import { google } from 'googleapis';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
import { waitForRunCompletion } from '../agents/utils.js';
|
||||||
import { serviceLogger } from '../services/service_logger.js';
|
import { serviceLogger } from '../services/service_logger.js';
|
||||||
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
||||||
import { GoogleClientFactory } from './google-client-factory.js';
|
import { GoogleClientFactory } from './google-client-factory.js';
|
||||||
|
|
@ -190,19 +190,6 @@ function extractConversationMessages(runFilePath: string): { role: string; text:
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Wait for agent run completion ---
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- User email resolution ---
|
// --- User email resolution ---
|
||||||
|
|
||||||
async function ensureUserEmail(): Promise<string | null> {
|
async function ensureUserEmail(): Promise<string | null> {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from 'path';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
import { bus } from '../runs/bus.js';
|
||||||
|
import { waitForRunCompletion } from '../agents/utils.js';
|
||||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||||
import {
|
import {
|
||||||
loadState,
|
loadState,
|
||||||
|
|
@ -24,6 +25,12 @@ import { getTagDefinitions } from './tag_system.js';
|
||||||
|
|
||||||
const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
|
const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
|
||||||
const NOTE_CREATION_AGENT = 'note_creation';
|
const NOTE_CREATION_AGENT = 'note_creation';
|
||||||
|
const SUGGESTED_TOPICS_REL_PATH = 'suggested-topics.md';
|
||||||
|
const SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'suggested-topics.md');
|
||||||
|
const LEGACY_SUGGESTED_TOPICS_REL_PATH = 'config/suggested-topics.md';
|
||||||
|
const LEGACY_SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'config', 'suggested-topics.md');
|
||||||
|
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH = 'knowledge/Notes/Suggested Topics.md';
|
||||||
|
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', 'Notes', 'Suggested Topics.md');
|
||||||
|
|
||||||
// Configuration for the graph builder service
|
// Configuration for the graph builder service
|
||||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||||
|
|
@ -87,6 +94,49 @@ function extractPathFromToolInput(input: string): string | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureSuggestedTopicsFileLocation(): string {
|
||||||
|
if (fs.existsSync(SUGGESTED_TOPICS_PATH)) {
|
||||||
|
return SUGGESTED_TOPICS_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyCandidates: Array<{ absPath: string; relPath: string }> = [
|
||||||
|
{ absPath: LEGACY_SUGGESTED_TOPICS_PATH, relPath: LEGACY_SUGGESTED_TOPICS_REL_PATH },
|
||||||
|
{ absPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH, relPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const legacy of legacyCandidates) {
|
||||||
|
if (!fs.existsSync(legacy.absPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacy.absPath, SUGGESTED_TOPICS_PATH);
|
||||||
|
console.log(`[buildGraph] Moved suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}`);
|
||||||
|
return SUGGESTED_TOPICS_PATH;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[buildGraph] Failed to move suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}:`, error);
|
||||||
|
return legacy.absPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SUGGESTED_TOPICS_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSuggestedTopicsFile(): string {
|
||||||
|
try {
|
||||||
|
const suggestedTopicsPath = ensureSuggestedTopicsFileLocation();
|
||||||
|
if (!fs.existsSync(suggestedTopicsPath)) {
|
||||||
|
return '_No existing suggested topics file._';
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(suggestedTopicsPath, 'utf-8').trim();
|
||||||
|
return content.length > 0 ? content : '_Existing suggested topics file is empty._';
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[buildGraph] Error reading suggested topics file:`, error);
|
||||||
|
return '_Failed to read existing suggested topics file._';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unprocessed voice memo files from knowledge/Voice Memos/
|
* Get unprocessed voice memo files from knowledge/Voice Memos/
|
||||||
* Voice memos are created directly in this directory by the UI.
|
* Voice memos are created directly in this directory by the UI.
|
||||||
|
|
@ -185,20 +235,6 @@ async function readFileContents(filePaths: string[]): Promise<{ path: string; co
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a run to complete by listening for run-processing-end event
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run note creation agent on a batch of files to extract entities and create/update notes
|
* Run note creation agent on a batch of files to extract entities and create/update notes
|
||||||
*/
|
*/
|
||||||
|
|
@ -216,6 +252,7 @@ async function createNotesFromBatch(
|
||||||
const run = await createRun({
|
const run = await createRun({
|
||||||
agentId: NOTE_CREATION_AGENT,
|
agentId: NOTE_CREATION_AGENT,
|
||||||
});
|
});
|
||||||
|
const suggestedTopicsContent = readSuggestedTopicsFile();
|
||||||
|
|
||||||
// Build message with index and all files in the batch
|
// Build message with index and all files in the batch
|
||||||
let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`;
|
let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`;
|
||||||
|
|
@ -223,8 +260,9 @@ async function createNotesFromBatch(
|
||||||
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
|
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
|
||||||
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
|
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
|
||||||
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
|
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
|
||||||
|
message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`;
|
||||||
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
|
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
|
||||||
message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`;
|
message += `- Use workspace tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
|
||||||
message += `- Follow the note templates and guidelines in your instructions\n\n`;
|
message += `- Follow the note templates and guidelines in your instructions\n\n`;
|
||||||
|
|
||||||
// Add the knowledge base index
|
// Add the knowledge base index
|
||||||
|
|
@ -232,6 +270,11 @@ async function createNotesFromBatch(
|
||||||
message += knowledgeIndex;
|
message += knowledgeIndex;
|
||||||
message += `\n---\n\n`;
|
message += `\n---\n\n`;
|
||||||
|
|
||||||
|
message += `# Current Suggested Topics File\n\n`;
|
||||||
|
message += `Path: ${SUGGESTED_TOPICS_REL_PATH}\n\n`;
|
||||||
|
message += suggestedTopicsContent;
|
||||||
|
message += `\n\n---\n\n`;
|
||||||
|
|
||||||
// Add each file's content
|
// Add each file's content
|
||||||
message += `# Source Files to Process\n\n`;
|
message += `# Source Files to Process\n\n`;
|
||||||
files.forEach((file, idx) => {
|
files.forEach((file, idx) => {
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,163 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { stringify as stringifyYaml } from 'yaml';
|
||||||
|
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
||||||
const TARGET_ID = 'dailybrief';
|
|
||||||
|
interface Section {
|
||||||
|
heading: string;
|
||||||
|
track: z.infer<typeof TrackBlockSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: Section[] = [
|
||||||
|
{
|
||||||
|
heading: '## ⏱ Up Next',
|
||||||
|
track: {
|
||||||
|
trackId: 'up-next',
|
||||||
|
instruction:
|
||||||
|
`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today.
|
||||||
|
|
||||||
|
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't started yet.
|
||||||
|
|
||||||
|
Lead based on how soon the next event is:
|
||||||
|
- Under 15 minutes → urgent ("Standup starts in 10 minutes — join link in the Calendar section below.")
|
||||||
|
- Under 2 hours → lead with the event ("Design review in 40 minutes.")
|
||||||
|
- 2+ hours → frame the gap as focus time ("Next up is standup at noon — you've got a solid 3-hour focus block.")
|
||||||
|
|
||||||
|
Always compute minutes-to-start against the actual current local time — never say "nothing in the next X hours" if an event is in that window.
|
||||||
|
|
||||||
|
If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research.
|
||||||
|
|
||||||
|
If nothing remains today, output exactly: Clear for the rest of the day.
|
||||||
|
|
||||||
|
Plain markdown prose only — no calendar block, no email block, no headings.`,
|
||||||
|
eventMatchCriteria:
|
||||||
|
`Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`,
|
||||||
|
active: true,
|
||||||
|
schedule: {
|
||||||
|
type: 'cron',
|
||||||
|
expression: '*/15 * * * *',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: '## 📅 Calendar',
|
||||||
|
track: {
|
||||||
|
trackId: 'calendar',
|
||||||
|
instruction:
|
||||||
|
`Emit today's meetings as a calendar block titled "Today's Meetings".
|
||||||
|
|
||||||
|
Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended — only include meetings that haven't ended yet.
|
||||||
|
|
||||||
|
Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink.
|
||||||
|
|
||||||
|
After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight — one line each, only when meaningful. Skip routine/recurring meetings.`,
|
||||||
|
eventMatchCriteria:
|
||||||
|
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
|
||||||
|
active: true,
|
||||||
|
schedule: {
|
||||||
|
type: 'cron',
|
||||||
|
expression: '0 * * * *',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: '## 📧 Emails',
|
||||||
|
track: {
|
||||||
|
trackId: 'emails',
|
||||||
|
instruction:
|
||||||
|
`Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread).
|
||||||
|
|
||||||
|
Event-driven path (primary): the agent message will include a freshly-synced thread's markdown as the event payload. Decide whether THIS thread warrants surfacing. If it's marketing, an auto-notification, a thread already closed out, or otherwise low-signal, skip the update — do NOT call update-track-content. If it's attention-worthy, integrate it into the digest: add a new email block, or update the existing one if the same threadId is already shown.
|
||||||
|
|
||||||
|
Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads.
|
||||||
|
|
||||||
|
Each email block should include threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add a draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response.
|
||||||
|
|
||||||
|
If there is genuinely nothing to surface, output the single line: No new emails.
|
||||||
|
|
||||||
|
Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`,
|
||||||
|
eventMatchCriteria:
|
||||||
|
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: '## 📰 What You Missed',
|
||||||
|
track: {
|
||||||
|
trackId: 'what-you-missed',
|
||||||
|
instruction:
|
||||||
|
`Short markdown summary of what happened yesterday that matters this morning.
|
||||||
|
|
||||||
|
Data sources:
|
||||||
|
- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md — use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments.
|
||||||
|
- gmail_sync/ — skim for threads from yesterday that went unresolved or still need a reply.
|
||||||
|
|
||||||
|
Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them.
|
||||||
|
|
||||||
|
Write concise markdown — a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today.
|
||||||
|
|
||||||
|
If nothing notable happened, output exactly: Quiet day yesterday — nothing to flag.
|
||||||
|
|
||||||
|
Do NOT manufacture content to fill the section.`,
|
||||||
|
active: true,
|
||||||
|
schedule: {
|
||||||
|
type: 'cron',
|
||||||
|
expression: '0 7 * * *',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: '## ✅ Today\'s Priorities',
|
||||||
|
track: {
|
||||||
|
trackId: 'priorities',
|
||||||
|
instruction:
|
||||||
|
`Ranked markdown list of the real, actionable items the user should focus on today.
|
||||||
|
|
||||||
|
Data sources:
|
||||||
|
- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ — action items assigned to the user are often the most important source.
|
||||||
|
- knowledge/ — use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups.
|
||||||
|
- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section — useful for alignment.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do NOT list calendar events as tasks — they're already in the Calendar section.
|
||||||
|
- Do NOT list trivial admin (filing small invoices, archiving spam).
|
||||||
|
- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review").
|
||||||
|
- Add a brief reason for each item when it's not self-evident.
|
||||||
|
|
||||||
|
If nothing genuinely needs attention, output exactly: No pressing tasks today — good day to make progress on bigger items.
|
||||||
|
|
||||||
|
Do NOT invent busywork.`,
|
||||||
|
active: true,
|
||||||
|
schedule: {
|
||||||
|
type: 'cron',
|
||||||
|
expression: '30 7 * * *',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function buildDailyNoteContent(): string {
|
function buildDailyNoteContent(): string {
|
||||||
const now = new Date();
|
const parts: string[] = ['# Today', ''];
|
||||||
const startDate = now.toISOString();
|
for (const { heading, track } of SECTIONS) {
|
||||||
const endDate = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd();
|
||||||
|
parts.push(
|
||||||
const instruction = 'Create a daily brief for me';
|
heading,
|
||||||
|
'',
|
||||||
const taskBlock = JSON.stringify({
|
'```track',
|
||||||
instruction,
|
yaml,
|
||||||
schedule: {
|
'```',
|
||||||
type: 'cron',
|
'',
|
||||||
expression: '*/15 * * * *',
|
`<!--track-target:${track.trackId}-->`,
|
||||||
startDate,
|
`<!--/track-target:${track.trackId}-->`,
|
||||||
endDate,
|
'',
|
||||||
},
|
);
|
||||||
'schedule-label': 'runs every 15 minutes',
|
}
|
||||||
targetId: TARGET_ID,
|
return parts.join('\n');
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'live_note: true',
|
|
||||||
'---',
|
|
||||||
'# Today',
|
|
||||||
'',
|
|
||||||
'```task',
|
|
||||||
taskBlock,
|
|
||||||
'```',
|
|
||||||
'',
|
|
||||||
`<!--task-target:${TARGET_ID}-->`,
|
|
||||||
`<!--/task-target:${TARGET_ID}-->`,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureDailyNote(): void {
|
export function ensureDailyNote(): void {
|
||||||
|
|
|
||||||
18
apps/x/packages/core/src/knowledge/file-lock.ts
Normal file
18
apps/x/packages/core/src/knowledge/file-lock.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const locks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
export async function withFileLock<T>(absPath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = locks.get(absPath) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => { release = r; });
|
||||||
|
const myTail = prev.then(() => gate);
|
||||||
|
locks.set(absPath, myTail);
|
||||||
|
try {
|
||||||
|
await prev;
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (locks.get(absPath) === myTail) {
|
||||||
|
locks.delete(absPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,11 @@ import { CronExpressionParser } from 'cron-parser';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
|
||||||
import container from '../di/container.js';
|
import container from '../di/container.js';
|
||||||
import type { IModelConfigRepo } from '../models/repo.js';
|
import type { IModelConfigRepo } from '../models/repo.js';
|
||||||
import { createProvider } from '../models/models.js';
|
import { createProvider } from '../models/models.js';
|
||||||
import { inlineTask } from '@x/shared';
|
import { inlineTask } from '@x/shared';
|
||||||
|
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
|
||||||
|
|
||||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||||
const INLINE_TASK_AGENT = 'inline_task_agent';
|
const INLINE_TASK_AGENT = 'inline_task_agent';
|
||||||
|
|
@ -129,46 +129,6 @@ function scanDirectoryRecursive(dir: string): string[] {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a run to complete by listening for run-processing-end event
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the assistant's final text response from a run's log.
|
|
||||||
*/
|
|
||||||
async function extractAgentResponse(runId: string): Promise<string | null> {
|
|
||||||
const run = await fetchRun(runId);
|
|
||||||
// Walk backwards through the log to find the last assistant message
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Content may be an array of parts — concatenate text parts
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
const text = content
|
|
||||||
.filter((p) => p.type === 'text')
|
|
||||||
.map((p) => (p as { type: 'text'; text: string }).text)
|
|
||||||
.join('');
|
|
||||||
return text || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InlineTask {
|
interface InlineTask {
|
||||||
instruction: string;
|
instruction: string;
|
||||||
schedule: InlineTaskSchedule | null;
|
schedule: InlineTaskSchedule | null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from 'path';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
import { bus } from '../runs/bus.js';
|
||||||
|
import { waitForRunCompletion } from '../agents/utils.js';
|
||||||
import { serviceLogger } from '../services/service_logger.js';
|
import { serviceLogger } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -62,20 +63,6 @@ function getUnlabeledEmails(state: LabelingState): string[] {
|
||||||
return unlabeled;
|
return unlabeled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a run to complete by listening for run-processing-end event
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Label a batch of email files using the labeling agent
|
* Label a batch of email files using the labeling agent
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -485,9 +485,9 @@ RESOLVED (use canonical name with absolute path):
|
||||||
- "Acme", "Acme Corp", "@acme.com" → [[Organizations/Acme Corp]]
|
- "Acme", "Acme Corp", "@acme.com" → [[Organizations/Acme Corp]]
|
||||||
- "the pilot", "the integration" → [[Projects/Acme Integration]]
|
- "the pilot", "the integration" → [[Projects/Acme Integration]]
|
||||||
|
|
||||||
NEW ENTITIES (create notes if source passes filters):
|
NEW ENTITIES (create notes or suggestion cards if source passes filters):
|
||||||
- "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]]
|
- "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]]
|
||||||
- "SOC 2" → Create [[Topics/Security Compliance]]
|
- "SOC 2" → Add or update a suggestion card in \`suggested-topics.md\` with category \`Topics\`
|
||||||
|
|
||||||
AMBIGUOUS (flag or skip):
|
AMBIGUOUS (flag or skip):
|
||||||
- "Mike" (no context) → Mention in activity only, don't create note
|
- "Mike" (no context) → Mention in activity only, don't create note
|
||||||
|
|
@ -508,8 +508,8 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
||||||
|
|
||||||
**CREATE a note for people who are:**
|
**CREATE a note for people who are:**
|
||||||
- External (not @user.domain)
|
- External (not @user.domain)
|
||||||
- Attendees in meetings
|
- People you directly interacted with in meetings
|
||||||
- Email correspondents (emails that reach this step already passed label-based filtering)
|
- Email correspondents directly participating in the thread (emails that reach this step already passed label-based filtering)
|
||||||
- Decision makers or contacts at customers, prospects, or partners
|
- Decision makers or contacts at customers, prospects, or partners
|
||||||
- Investors or potential investors
|
- Investors or potential investors
|
||||||
- Candidates you are interviewing
|
- Candidates you are interviewing
|
||||||
|
|
@ -521,6 +521,7 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
||||||
- Large group meeting attendees you didn't interact with
|
- Large group meeting attendees you didn't interact with
|
||||||
- Internal colleagues (@user.domain)
|
- Internal colleagues (@user.domain)
|
||||||
- Assistants handling only logistics
|
- Assistants handling only logistics
|
||||||
|
- People mentioned only as third parties ("we work with X", "I can introduce you to Y") when there has been no direct interaction yet
|
||||||
|
|
||||||
### Role Inference
|
### Role Inference
|
||||||
|
|
||||||
|
|
@ -579,31 +580,155 @@ For people who don't warrant their own note, add to Organization note's Contacts
|
||||||
- Sarah Lee — Support, handled wire transfer issue
|
- Sarah Lee — Support, handled wire transfer issue
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
### Direct Interaction Test (People and Organizations)
|
||||||
|
|
||||||
|
For **new canonical People and Organizations notes**, require **direct interaction**, not just mention.
|
||||||
|
|
||||||
|
**Direct interaction = YES**
|
||||||
|
- The person sent the email, replied in the thread, or was directly addressed as part of the active exchange
|
||||||
|
- The person participated in the meeting, and there is evidence the user actually interacted with them or the meeting centered on them
|
||||||
|
- The organization is directly represented in the exchange by participants/senders and is part of an active first-degree relationship with the user or team
|
||||||
|
- The user is directly evaluating, selling to, buying from, partnering with, interviewing, or coordinating with that person or organization
|
||||||
|
|
||||||
|
**Direct interaction = NO**
|
||||||
|
- Someone else mentions them in passing
|
||||||
|
- A sender says they work with someone at another company
|
||||||
|
- A sender offers to introduce the user to someone
|
||||||
|
- A company is referenced as a customer, partner, employer, competitor, or example, but nobody from that company is directly involved in the interaction
|
||||||
|
- The source only establishes a second-degree relationship, not a direct one
|
||||||
|
|
||||||
|
**Canonical note rule:**
|
||||||
|
- For **new People/Organizations**, create the canonical note only if both are true:
|
||||||
|
1. There is **direct interaction**
|
||||||
|
2. The entity clears the **weekly importance test**
|
||||||
|
|
||||||
|
If an entity seems strategically relevant but fails the direct interaction test, do **not** auto-create a canonical note. At most, create a suggestion card in \`suggested-topics.md\`.
|
||||||
|
|
||||||
|
### Weekly Importance Test (People and Organizations only)
|
||||||
|
|
||||||
|
For **People** and **Organizations**, the final gate for **creating a new canonical note** is an importance test:
|
||||||
|
|
||||||
|
**Ask:** _"If I were the user, would I realistically need to look at this note on a weekly basis over the near term?"_
|
||||||
|
|
||||||
|
This test is mainly for **People** and **Organizations**. **Do NOT use it as the decision rule for Topic or Project suggestions.**
|
||||||
|
|
||||||
|
**Strong YES signals:**
|
||||||
|
- Active customer, prospect, investor, partner, candidate, advisor, or strategic vendor relationship
|
||||||
|
- Repeated interaction or a likely ongoing cadence
|
||||||
|
- Decision-maker, owner, blocker, evaluator, or approver in an active process
|
||||||
|
- Material relevance to launch, sales, fundraising, hiring, compliance, product delivery, or another current priority
|
||||||
|
- The user would benefit from a durable reference note instead of repeatedly reopening raw emails or meeting transcripts
|
||||||
|
|
||||||
|
**Strong NO signals:**
|
||||||
|
- One-off logistics, scheduling, or transactional contact
|
||||||
|
- Assistant, support rep, recruiter, or vendor rep with no ongoing strategic role
|
||||||
|
- Incidental attendee mentioned once with no leverage on current work
|
||||||
|
- Passing mention with no evidence of an ongoing relationship
|
||||||
|
|
||||||
|
**Borderline signals:**
|
||||||
|
- Seems potentially important, but there isn't enough evidence yet that the user will need a weekly reference note
|
||||||
|
- Might become important soon, but the role, relationship, or repeated relevance is still unclear
|
||||||
|
- Important enough to track, but only through second-degree mention or an offered introduction rather than direct interaction
|
||||||
|
|
||||||
|
**Outcome rules for new People/Organizations:**
|
||||||
|
- **Clear YES + direct interaction** → Create/update the canonical \`People/\` or \`Organizations/\` note
|
||||||
|
- **Borderline or no direct interaction, but still strategically relevant** → Do **not** create the canonical note yet; instead create or update a card in \`suggested-topics.md\`
|
||||||
|
- **Clear NO** → Skip note creation and do not add a suggestion unless the source strongly suggests near-term strategic relevance
|
||||||
|
|
||||||
|
**When a canonical note already exists:**
|
||||||
|
- Update the existing note even if the current source is weaker; the importance test is mainly for deciding whether to create a **new** People/Organization note
|
||||||
|
- If a previously tentative person/org is now clearly important enough for a canonical note, create/update the note and remove any tentative suggestion card for that exact entity from \`suggested-topics.md\`
|
||||||
|
|
||||||
## Organizations
|
## Organizations
|
||||||
|
|
||||||
**CREATE a note if:**
|
**CREATE a note if:**
|
||||||
- Someone from that org attended a meeting
|
- There is direct interaction with that org in the source
|
||||||
- They're a customer, prospect, investor, or partner
|
- They're a customer, prospect, investor, or partner in a direct first-degree interaction
|
||||||
- Someone from that org sent relevant personalized correspondence
|
- Someone from that org sent relevant personalized correspondence or joined a meeting you actually had with them
|
||||||
|
- They pass the weekly importance test above
|
||||||
|
|
||||||
**DO NOT create for:**
|
**DO NOT create for:**
|
||||||
- Tool/service providers mentioned in passing
|
- Tool/service providers mentioned in passing
|
||||||
- One-time transactional vendors
|
- One-time transactional vendors
|
||||||
- Consumer service companies
|
- Consumer service companies
|
||||||
|
- Organizations only referenced through third-party mention or offered introductions
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
**CREATE a note if:**
|
**If a project note already exists:** update it.
|
||||||
|
|
||||||
|
**If no project note exists:** do **not** create a new canonical note in \`knowledge/Projects/\`.
|
||||||
|
|
||||||
|
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the project is strong enough:
|
||||||
- Discussed substantively in a meeting or email thread
|
- Discussed substantively in a meeting or email thread
|
||||||
- Has a goal and timeline
|
- Has a goal and timeline
|
||||||
- Involves multiple interactions
|
- Involves multiple interactions
|
||||||
|
|
||||||
|
Otherwise skip it.
|
||||||
|
|
||||||
|
Projects do **not** use the weekly importance test above. For **new** projects, the default output is a suggestion card, not a canonical note.
|
||||||
|
|
||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
**CREATE a note if:**
|
**If a topic note already exists:** update it.
|
||||||
|
|
||||||
|
**If no topic note exists:** do **not** create a new canonical note in \`knowledge/Topics/\`.
|
||||||
|
|
||||||
|
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the topic is strong enough:
|
||||||
- Recurring theme discussed
|
- Recurring theme discussed
|
||||||
- Will come up again across conversations
|
- Will come up again across conversations
|
||||||
|
|
||||||
|
Otherwise skip it.
|
||||||
|
|
||||||
|
Topics do **not** use the weekly importance test above. For **new** topics, the default output is a suggestion card, not a canonical note.
|
||||||
|
|
||||||
|
## Suggested Topics Curation
|
||||||
|
|
||||||
|
Also maintain \`suggested-topics.md\` as a **curated shortlist** of things worth exploring next.
|
||||||
|
|
||||||
|
Despite the filename, \`suggested-topics.md\` can contain cards for **People, Organizations, Topics, or Projects**.
|
||||||
|
|
||||||
|
There are **two reasons** to add or update a suggestion card:
|
||||||
|
|
||||||
|
1. **High-quality Topic/Project cards**
|
||||||
|
- Use these for topics or projects that are timely, high-leverage, strategically important, or clearly worth exploring now
|
||||||
|
- These are not a dump of every topic/project note. Be selective
|
||||||
|
- For **new** topics and projects, cards are the default output from this pipeline
|
||||||
|
|
||||||
|
2. **Tentative People/Organization cards**
|
||||||
|
- Use these when a person or organization seems important enough to track, but you are **not 100% sure** they clear the weekly-importance test for a canonical note yet
|
||||||
|
- The card should capture why they might matter and what still needs verification
|
||||||
|
|
||||||
|
**Do NOT add cards for:**
|
||||||
|
- Low-signal administrative or transactional entities
|
||||||
|
- Stale or completed items with no near-term relevance
|
||||||
|
- People/organizations that already have a clearly established canonical note, unless the card is about a distinct project/topic exploration rather than the entity itself
|
||||||
|
|
||||||
|
**Card guidance:**
|
||||||
|
- For **Topics/Projects**, use category \`Topics\` or \`Projects\`
|
||||||
|
- For tentative **People/Organizations**, use category \`People\` or \`Organizations\`
|
||||||
|
- Title should be concise and canonical when possible
|
||||||
|
- Description should explain why it matters **now**
|
||||||
|
- For tentative People/Organizations, description should also mention what is still uncertain or what the user should verify
|
||||||
|
|
||||||
|
**Curation rules:**
|
||||||
|
- Maintain a **high-quality set**, not an ever-growing backlog
|
||||||
|
- Deduplicate by normalized title
|
||||||
|
- Prefer current, actionable, recurring, or strategically important items
|
||||||
|
- Keep only the strongest **8-12 cards total**
|
||||||
|
- Preserve good existing cards unless the new source clearly supersedes them
|
||||||
|
- Remove stale cards that are no longer relevant
|
||||||
|
- If a tentative People/Organization card later becomes clearly important and you create a canonical note, remove the tentative card
|
||||||
|
|
||||||
|
**File format for \`suggested-topics.md\`:**
|
||||||
|
\`\`\`suggestedtopic
|
||||||
|
{"title":"Security Compliance","description":"Summarize the current compliance posture, blockers, and customer implications.","category":"Topics"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The file should start with \`# Suggested Topics\` followed by one or more blocks in that format.
|
||||||
|
|
||||||
|
If the file does not exist, create it. If it exists, update it in place or rewrite the full file so the final result is clean, deduped, and curated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step 6: Extract Content
|
# Step 6: Extract Content
|
||||||
|
|
@ -824,7 +949,7 @@ If new info contradicts existing:
|
||||||
|
|
||||||
# Step 9: Write Updates
|
# Step 9: Write Updates
|
||||||
|
|
||||||
## 9a: Create and Update Notes
|
## 9a: Create and Update Notes and Suggested Topic Cards
|
||||||
|
|
||||||
**IMPORTANT: Write sequentially, one file at a time.**
|
**IMPORTANT: Write sequentially, one file at a time.**
|
||||||
- Generate content for exactly one note.
|
- Generate content for exactly one note.
|
||||||
|
|
@ -852,6 +977,12 @@ workspace-edit({
|
||||||
})
|
})
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
**For \`suggested-topics.md\`:**
|
||||||
|
- Use workspace-relative path \`suggested-topics.md\`
|
||||||
|
- Read the current file if you need the latest content
|
||||||
|
- Use \`workspace-writeFile\` to create or rewrite the file when that is simpler and cleaner
|
||||||
|
- Use \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable
|
||||||
|
|
||||||
## 9b: Apply State Changes
|
## 9b: Apply State Changes
|
||||||
|
|
||||||
For each state change identified in Step 7, update the relevant fields.
|
For each state change identified in Step 7, update the relevant fields.
|
||||||
|
|
@ -867,8 +998,9 @@ If you discovered new name variants during resolution, add them to Aliases field
|
||||||
- Be concise: one line per activity entry
|
- Be concise: one line per activity entry
|
||||||
- Note state changes with \`[Field → value]\` in activity
|
- Note state changes with \`[Field → value]\` in activity
|
||||||
- Escape quotes properly in shell commands
|
- Escape quotes properly in shell commands
|
||||||
- Write only one file per response (no multi-file write batches)
|
- Write only one file per response (notes and \`suggested-topics.md\` follow the same rule)
|
||||||
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
|
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
|
||||||
|
- Keep \`suggested-topics.md\` curated, deduped, and capped to the strongest 8-12 cards
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -957,8 +1089,12 @@ Before completing, verify:
|
||||||
**Filtering:**
|
**Filtering:**
|
||||||
- [ ] Excluded self (user.name, user.email, @user.domain)
|
- [ ] Excluded self (user.name, user.email, @user.domain)
|
||||||
- [ ] Applied relevance test to each person
|
- [ ] Applied relevance test to each person
|
||||||
|
- [ ] Applied the direct interaction test to new People/Organizations
|
||||||
|
- [ ] Applied the weekly importance test to new People/Organizations
|
||||||
- [ ] Transactional contacts in Org Contacts, not People notes
|
- [ ] Transactional contacts in Org Contacts, not People notes
|
||||||
- [ ] Source correctly classified (process vs skip)
|
- [ ] Source correctly classified (process vs skip)
|
||||||
|
- [ ] Third-party mentions did not become new canonical People/Organizations notes
|
||||||
|
- [ ] Borderline People/Organizations became suggestion cards instead of canonical notes
|
||||||
|
|
||||||
**Content Quality:**
|
**Content Quality:**
|
||||||
- [ ] Summaries describe relationship, not communication method
|
- [ ] Summaries describe relationship, not communication method
|
||||||
|
|
@ -978,8 +1114,11 @@ Before completing, verify:
|
||||||
- [ ] All entity mentions use \`[[Folder/Name]]\` absolute links
|
- [ ] All entity mentions use \`[[Folder/Name]]\` absolute links
|
||||||
- [ ] Activity entries are reverse chronological
|
- [ ] Activity entries are reverse chronological
|
||||||
- [ ] No duplicate activity entries
|
- [ ] No duplicate activity entries
|
||||||
|
- [ ] \`suggested-topics.md\` stays deduped and curated
|
||||||
|
- [ ] High-quality Topics/Projects were added to suggested topics only when timely and useful
|
||||||
|
- [ ] New Topics/Projects were not auto-created as canonical notes
|
||||||
- [ ] Dates are YYYY-MM-DD
|
- [ ] Dates are YYYY-MM-DD
|
||||||
- [ ] Bidirectional links are consistent
|
- [ ] Bidirectional links are consistent
|
||||||
- [ ] New notes in correct folders
|
- [ ] New notes in correct folders
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,130 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
|
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
|
||||||
import { composioAccountsRepo } from '../composio/repo.js';
|
import { composioAccountsRepo } from '../composio/repo.js';
|
||||||
|
import { createEvent } from './track/events.js';
|
||||||
|
|
||||||
|
const MAX_EVENTS_IN_DIGEST = 50;
|
||||||
|
const MAX_DESCRIPTION_CHARS = 500;
|
||||||
|
|
||||||
|
type AnyEvent = Record<string, unknown> | cal.Schema$Event;
|
||||||
|
|
||||||
|
function getStr(obj: unknown, key: string): string | undefined {
|
||||||
|
if (obj && typeof obj === 'object' && key in obj) {
|
||||||
|
const v = (obj as Record<string, unknown>)[key];
|
||||||
|
return typeof v === 'string' ? v : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(event: AnyEvent): string {
|
||||||
|
const start = (event as Record<string, unknown>).start as Record<string, unknown> | undefined;
|
||||||
|
const end = (event as Record<string, unknown>).end as Record<string, unknown> | undefined;
|
||||||
|
const startStr = getStr(start, 'dateTime') ?? getStr(start, 'date') ?? 'unknown';
|
||||||
|
const endStr = getStr(end, 'dateTime') ?? getStr(end, 'date') ?? 'unknown';
|
||||||
|
return `${startStr} → ${endStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventBlock(event: AnyEvent, label: 'NEW' | 'UPDATED'): string {
|
||||||
|
const id = getStr(event, 'id') ?? '(unknown id)';
|
||||||
|
const title = getStr(event, 'summary') ?? '(no title)';
|
||||||
|
const time = formatEventTime(event);
|
||||||
|
const organizer = getStr((event as Record<string, unknown>).organizer, 'email') ?? 'unknown';
|
||||||
|
const location = getStr(event, 'location') ?? '';
|
||||||
|
const rawDescription = getStr(event, 'description') ?? '';
|
||||||
|
const description = rawDescription.length > MAX_DESCRIPTION_CHARS
|
||||||
|
? rawDescription.slice(0, MAX_DESCRIPTION_CHARS) + '…(truncated)'
|
||||||
|
: rawDescription;
|
||||||
|
|
||||||
|
const attendeesRaw = (event as Record<string, unknown>).attendees;
|
||||||
|
let attendeesLine = '';
|
||||||
|
if (Array.isArray(attendeesRaw) && attendeesRaw.length > 0) {
|
||||||
|
const emails = attendeesRaw
|
||||||
|
.map(a => getStr(a, 'email'))
|
||||||
|
.filter((e): e is string => !!e);
|
||||||
|
if (emails.length > 0) {
|
||||||
|
attendeesLine = `**Attendees:** ${emails.join(', ')}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`### [${label}] ${title}`,
|
||||||
|
`**ID:** ${id}`,
|
||||||
|
`**Time:** ${time}`,
|
||||||
|
`**Organizer:** ${organizer}`,
|
||||||
|
location ? `**Location:** ${location}` : '',
|
||||||
|
attendeesLine.trimEnd(),
|
||||||
|
description ? `\n${description}` : '',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeCalendarSync(
|
||||||
|
newEvents: AnyEvent[],
|
||||||
|
updatedEvents: AnyEvent[],
|
||||||
|
deletedEventIds: string[],
|
||||||
|
): string {
|
||||||
|
const totalChanges = newEvents.length + updatedEvents.length + deletedEventIds.length;
|
||||||
|
const lines: string[] = [
|
||||||
|
`# Calendar sync update`,
|
||||||
|
``,
|
||||||
|
`${newEvents.length} new, ${updatedEvents.length} updated, ${deletedEventIds.length} deleted.`,
|
||||||
|
``,
|
||||||
|
];
|
||||||
|
|
||||||
|
const allChanges: Array<{ event: AnyEvent; label: 'NEW' | 'UPDATED' }> = [
|
||||||
|
...newEvents.map(e => ({ event: e, label: 'NEW' as const })),
|
||||||
|
...updatedEvents.map(e => ({ event: e, label: 'UPDATED' as const })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const shown = allChanges.slice(0, MAX_EVENTS_IN_DIGEST);
|
||||||
|
const hidden = allChanges.length - shown.length;
|
||||||
|
|
||||||
|
if (shown.length > 0) {
|
||||||
|
lines.push(`## Changed events`, ``);
|
||||||
|
for (const { event, label } of shown) {
|
||||||
|
lines.push(formatEventBlock(event, label), ``);
|
||||||
|
}
|
||||||
|
if (hidden > 0) {
|
||||||
|
lines.push(`_…and ${hidden} more change(s) omitted from digest._`, ``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedEventIds.length > 0) {
|
||||||
|
lines.push(`## Deleted event IDs`, ``);
|
||||||
|
for (const id of deletedEventIds.slice(0, MAX_EVENTS_IN_DIGEST)) {
|
||||||
|
lines.push(`- ${id}`);
|
||||||
|
}
|
||||||
|
if (deletedEventIds.length > MAX_EVENTS_IN_DIGEST) {
|
||||||
|
lines.push(`- _…and ${deletedEventIds.length - MAX_EVENTS_IN_DIGEST} more_`);
|
||||||
|
}
|
||||||
|
lines.push(``);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalChanges === 0) {
|
||||||
|
lines.push(`(no changes — should not be emitted)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishCalendarSyncEvent(
|
||||||
|
newEvents: AnyEvent[],
|
||||||
|
updatedEvents: AnyEvent[],
|
||||||
|
deletedEventIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (newEvents.length === 0 && updatedEvents.length === 0 && deletedEventIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createEvent({
|
||||||
|
source: 'calendar',
|
||||||
|
type: 'calendar.synced',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
payload: summarizeCalendarSync(newEvents, updatedEvents, deletedEventIds),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Calendar] Failed to publish sync event:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||||
|
|
@ -194,6 +318,8 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let attachmentCount = 0;
|
let attachmentCount = 0;
|
||||||
const changedTitles: string[] = [];
|
const changedTitles: string[] = [];
|
||||||
|
const newEvents: AnyEvent[] = [];
|
||||||
|
const updatedEvents: AnyEvent[] = [];
|
||||||
|
|
||||||
const ensureRun = async () => {
|
const ensureRun = async () => {
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
|
|
@ -234,8 +360,10 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
|
||||||
changedTitles.push(result.title);
|
changedTitles.push(result.title);
|
||||||
if (result.isNew) {
|
if (result.isNew) {
|
||||||
newCount++;
|
newCount++;
|
||||||
|
newEvents.push(event);
|
||||||
} else {
|
} else {
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
updatedEvents.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,6 +381,9 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
|
||||||
deletedCount = deletedFiles.length;
|
deletedCount = deletedFiles.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish a single bundled event capturing all changes from this sync.
|
||||||
|
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
|
||||||
|
|
||||||
if (runId) {
|
if (runId) {
|
||||||
const totalChanges = newCount + updatedCount + deletedCount + attachmentCount;
|
const totalChanges = newCount + updatedCount + deletedCount + attachmentCount;
|
||||||
const limitedTitles = limitEventItems(changedTitles);
|
const limitedTitles = limitEventItems(changedTitles);
|
||||||
|
|
@ -438,6 +569,8 @@ async function performSyncComposio() {
|
||||||
let newCount = 0;
|
let newCount = 0;
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
const changedTitles: string[] = [];
|
const changedTitles: string[] = [];
|
||||||
|
const newEvents: AnyEvent[] = [];
|
||||||
|
const updatedEvents: AnyEvent[] = [];
|
||||||
let pageToken: string | null = null;
|
let pageToken: string | null = null;
|
||||||
const MAX_PAGES = 20;
|
const MAX_PAGES = 20;
|
||||||
|
|
||||||
|
|
@ -508,8 +641,10 @@ async function performSyncComposio() {
|
||||||
changedTitles.push(saveResult.title);
|
changedTitles.push(saveResult.title);
|
||||||
if (saveResult.isNew) {
|
if (saveResult.isNew) {
|
||||||
newCount++;
|
newCount++;
|
||||||
|
newEvents.push(event);
|
||||||
} else {
|
} else {
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
updatedEvents.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -534,6 +669,9 @@ async function performSyncComposio() {
|
||||||
deletedCount = deletedFiles.length;
|
deletedCount = deletedFiles.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish a single bundled event capturing all changes from this sync.
|
||||||
|
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
|
||||||
|
|
||||||
// Log results if any changes were detected (run was started by ensureRun)
|
// Log results if any changes were detected (run was started by ensureRun)
|
||||||
if (run) {
|
if (run) {
|
||||||
const r = run as ServiceRunContext;
|
const r = run as ServiceRunContext;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import { executeAction, useComposioForGoogle } from '../composio/client.js';
|
import { executeAction, useComposioForGoogle } from '../composio/client.js';
|
||||||
import { composioAccountsRepo } from '../composio/repo.js';
|
import { composioAccountsRepo } from '../composio/repo.js';
|
||||||
|
import { createEvent } from './track/events.js';
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||||
|
|
@ -172,6 +173,13 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
|
||||||
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
||||||
console.log(`Synced Thread: ${subject} (${threadId})`);
|
console.log(`Synced Thread: ${subject} (${threadId})`);
|
||||||
|
|
||||||
|
await createEvent({
|
||||||
|
source: 'gmail',
|
||||||
|
type: 'email.synced',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
payload: mdContent,
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing thread ${threadId}:`, error);
|
console.error(`Error processing thread ${threadId}:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -595,6 +603,12 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
|
||||||
|
|
||||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||||
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
||||||
|
await createEvent({
|
||||||
|
source: 'gmail',
|
||||||
|
type: 'email.synced',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
payload: mdContent,
|
||||||
|
});
|
||||||
newestDate = tryParseDate(parsed.date);
|
newestDate = tryParseDate(parsed.date);
|
||||||
} else {
|
} else {
|
||||||
const firstParsed = parseMessageData(messages[0]);
|
const firstParsed = parseMessageData(messages[0]);
|
||||||
|
|
@ -617,6 +631,12 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
|
||||||
|
|
||||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||||
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
||||||
|
await createEvent({
|
||||||
|
source: 'gmail',
|
||||||
|
type: 'email.synced',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
payload: mdContent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newestDate) return null;
|
if (!newestDate) return null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from 'path';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
import { bus } from '../runs/bus.js';
|
||||||
|
import { waitForRunCompletion } from '../agents/utils.js';
|
||||||
import { serviceLogger } from '../services/service_logger.js';
|
import { serviceLogger } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,20 +76,6 @@ function getUntaggedNotes(state: NoteTaggingState): string[] {
|
||||||
return untagged;
|
return untagged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a run to complete by listening for run-processing-end event
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag a batch of note files using the tagging agent
|
* Tag a batch of note files using the tagging agent
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
23
apps/x/packages/core/src/knowledge/track/bus.ts
Normal file
23
apps/x/packages/core/src/knowledge/track/bus.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { TrackEventType } from '@x/shared/dist/track-block.js';
|
||||||
|
|
||||||
|
type Handler = (event: TrackEventType) => void;
|
||||||
|
|
||||||
|
class TrackBus {
|
||||||
|
private subs: Handler[] = [];
|
||||||
|
|
||||||
|
publish(event: TrackEventType): void {
|
||||||
|
for (const handler of this.subs) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(handler: Handler): () => void {
|
||||||
|
this.subs.push(handler);
|
||||||
|
return () => {
|
||||||
|
const idx = this.subs.indexOf(handler);
|
||||||
|
if (idx >= 0) this.subs.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackBus = new TrackBus();
|
||||||
189
apps/x/packages/core/src/knowledge/track/events.ts
Normal file
189
apps/x/packages/core/src/knowledge/track/events.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { PrefixLogger, trackBlock } from '@x/shared';
|
||||||
|
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||||
|
import { WorkDir } from '../../config/config.js';
|
||||||
|
import * as workspace from '../../workspace/workspace.js';
|
||||||
|
import { fetchAll } from './fileops.js';
|
||||||
|
import { triggerTrackUpdate } from './runner.js';
|
||||||
|
import { findCandidates, type ParsedTrack } from './routing.js';
|
||||||
|
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
|
||||||
|
import container from '../../di/container.js';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5_000; // 5 seconds — events should feel responsive
|
||||||
|
const EVENTS_DIR = path.join(WorkDir, 'events');
|
||||||
|
const PENDING_DIR = path.join(EVENTS_DIR, 'pending');
|
||||||
|
const DONE_DIR = path.join(EVENTS_DIR, 'done');
|
||||||
|
|
||||||
|
const log = new PrefixLogger('EventProcessor');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a KnowledgeEvent to the events/pending/ directory.
|
||||||
|
* Filename is a monotonically increasing ID so events sort by creation order.
|
||||||
|
* Call this function in chronological order (oldest event first) within a sync batch
|
||||||
|
* to ensure correct ordering.
|
||||||
|
*/
|
||||||
|
export async function createEvent(event: Omit<KnowledgeEvent, 'id'>): Promise<void> {
|
||||||
|
fs.mkdirSync(PENDING_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const idGen = container.resolve<IMonotonicallyIncreasingIdGenerator>('idGenerator');
|
||||||
|
const id = await idGen.next();
|
||||||
|
|
||||||
|
const fullEvent: KnowledgeEvent = { id, ...event };
|
||||||
|
const filePath = path.join(PENDING_DIR, `${id}.json`);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(fullEvent, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDirs(): void {
|
||||||
|
fs.mkdirSync(PENDING_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(DONE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAllTracks(): Promise<ParsedTrack[]> {
|
||||||
|
const tracks: ParsedTrack[] = [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await workspace.readdir('knowledge', { recursive: true });
|
||||||
|
} catch {
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
const mdFiles = entries
|
||||||
|
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
|
||||||
|
.map(e => e.path.replace(/^knowledge\//, ''));
|
||||||
|
|
||||||
|
for (const filePath of mdFiles) {
|
||||||
|
let parsedTracks;
|
||||||
|
try {
|
||||||
|
parsedTracks = await fetchAll(filePath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const t of parsedTracks) {
|
||||||
|
tracks.push({
|
||||||
|
trackId: t.track.trackId,
|
||||||
|
filePath,
|
||||||
|
eventMatchCriteria: t.track.eventMatchCriteria ?? '',
|
||||||
|
instruction: t.track.instruction,
|
||||||
|
active: t.track.active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
|
||||||
|
const donePath = path.join(DONE_DIR, filename);
|
||||||
|
const pendingPath = path.join(PENDING_DIR, filename);
|
||||||
|
fs.writeFileSync(donePath, JSON.stringify(enriched, null, 2), 'utf-8');
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(pendingPath);
|
||||||
|
} catch (err) {
|
||||||
|
log.log(`Failed to remove pending event ${filename}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processOneEvent(filename: string): Promise<void> {
|
||||||
|
const pendingPath = path.join(PENDING_DIR, filename);
|
||||||
|
|
||||||
|
let event: KnowledgeEvent;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(pendingPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
event = trackBlock.KnowledgeEventSchema.parse(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
log.log(`Malformed event ${filename}, moving to done with error:`, msg);
|
||||||
|
const stub: KnowledgeEvent = {
|
||||||
|
id: filename.replace(/\.json$/, ''),
|
||||||
|
source: 'unknown',
|
||||||
|
type: 'unknown',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
payload: '',
|
||||||
|
processedAt: new Date().toISOString(),
|
||||||
|
error: `Failed to parse: ${msg}`,
|
||||||
|
};
|
||||||
|
moveEventToDone(filename, stub);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.log(`Processing event ${event.id} (source=${event.source}, type=${event.type})`);
|
||||||
|
|
||||||
|
const allTracks = await listAllTracks();
|
||||||
|
const candidates = await findCandidates(event, allTracks);
|
||||||
|
|
||||||
|
const runIds: string[] = [];
|
||||||
|
let processingError: string | undefined;
|
||||||
|
|
||||||
|
// Sequential — preserves total ordering
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const result = await triggerTrackUpdate(
|
||||||
|
candidate.trackId,
|
||||||
|
candidate.filePath,
|
||||||
|
event.payload,
|
||||||
|
'event',
|
||||||
|
);
|
||||||
|
if (result.runId) runIds.push(result.runId);
|
||||||
|
log.log(`Candidate ${candidate.trackId}: ${result.action}${result.error ? ` (${result.error})` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
log.log(`Error triggering candidate ${candidate.trackId}:`, msg);
|
||||||
|
processingError = (processingError ? processingError + '; ' : '') + `${candidate.trackId}: ${msg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched: KnowledgeEvent = {
|
||||||
|
...event,
|
||||||
|
processedAt: new Date().toISOString(),
|
||||||
|
candidates: candidates.map(c => ({ trackId: c.trackId, filePath: c.filePath })),
|
||||||
|
runIds,
|
||||||
|
...(processingError ? { error: processingError } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
moveEventToDone(filename, enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPendingEvents(): Promise<void> {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
let filenames: string[];
|
||||||
|
try {
|
||||||
|
filenames = fs.readdirSync(PENDING_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
} catch (err) {
|
||||||
|
log.log('Failed to read pending dir:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filenames.length === 0) return;
|
||||||
|
|
||||||
|
// FIFO: monotonic IDs are lexicographically sortable
|
||||||
|
filenames.sort();
|
||||||
|
|
||||||
|
log.log(`Processing ${filenames.length} pending event(s)`);
|
||||||
|
|
||||||
|
for (const filename of filenames) {
|
||||||
|
try {
|
||||||
|
await processOneEvent(filename);
|
||||||
|
} catch (err) {
|
||||||
|
log.log(`Unhandled error processing ${filename}:`, err);
|
||||||
|
// Keep the loop alive — don't move file, will retry on next tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function init(): Promise<void> {
|
||||||
|
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await processPendingEvents();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
try {
|
||||||
|
await processPendingEvents();
|
||||||
|
} catch (err) {
|
||||||
|
log.log('Error in main loop:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
apps/x/packages/core/src/knowledge/track/fileops.ts
Normal file
199
apps/x/packages/core/src/knowledge/track/fileops.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import z from 'zod';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
|
import { WorkDir } from '../../config/config.js';
|
||||||
|
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||||
|
import { TrackStateSchema } from './types.js';
|
||||||
|
import { withFileLock } from '../file-lock.js';
|
||||||
|
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
|
||||||
|
function absPath(filePath: string): string {
|
||||||
|
return path.join(KNOWLEDGE_DIR, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const blocks: z.infer<typeof TrackStateSchema>[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const contentFenceStartMatcher = /<!--track-target:(.+)-->/;
|
||||||
|
const contentFenceEndMatcher = /<!--\/track-target:(.+)-->/;
|
||||||
|
while (i < lines.length) {
|
||||||
|
if (lines[i].trim() === '```track') {
|
||||||
|
const fenceStart = i;
|
||||||
|
i++;
|
||||||
|
const blockLines: string[] = [];
|
||||||
|
while (i < lines.length && lines[i].trim() !== '```') {
|
||||||
|
blockLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = parseYaml(blockLines.join('\n'));
|
||||||
|
const result = TrackBlockSchema.safeParse(data);
|
||||||
|
if (result.success) {
|
||||||
|
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
} else if (contentFenceStartMatcher.test(lines[i])) {
|
||||||
|
const match = contentFenceStartMatcher.exec(lines[i]);
|
||||||
|
if (match) {
|
||||||
|
const trackId = match[1];
|
||||||
|
// have we already collected this track block?
|
||||||
|
const existingBlock = blocks.find(b => b.track.trackId === trackId);
|
||||||
|
if (!existingBlock) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const contentStart = i + 1;
|
||||||
|
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const contentEnd = i;
|
||||||
|
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> {
|
||||||
|
const blocks = await fetchAll(filePath);
|
||||||
|
return blocks.find(b => b.track.trackId === trackId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a track block and return its canonical YAML string (or null if not found).
|
||||||
|
* Useful for IPC handlers that need to return the fresh YAML without taking a
|
||||||
|
* dependency on the `yaml` package themselves.
|
||||||
|
*/
|
||||||
|
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
|
||||||
|
const block = await fetch(filePath, trackId);
|
||||||
|
if (!block) return null;
|
||||||
|
return stringifyYaml(block.track).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
|
||||||
|
return withFileLock(absPath(filePath), async () => {
|
||||||
|
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||||
|
const openTag = `<!--track-target:${trackId}-->`;
|
||||||
|
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||||
|
const openIdx = content.indexOf(openTag);
|
||||||
|
const closeIdx = content.indexOf(closeTag);
|
||||||
|
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||||
|
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
|
||||||
|
} else {
|
||||||
|
const block = await fetch(filePath, trackId);
|
||||||
|
if (!block) {
|
||||||
|
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||||
|
}
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
|
||||||
|
const contentFence = [openTag, newContent, closeTag];
|
||||||
|
lines.splice(insertAt, 0, ...contentFence);
|
||||||
|
content = lines.join('\n');
|
||||||
|
}
|
||||||
|
await fs.writeFile(absPath(filePath), content, 'utf-8');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
|
||||||
|
return withFileLock(absPath(filepath), async () => {
|
||||||
|
const block = await fetch(filepath, trackId);
|
||||||
|
if (!block) {
|
||||||
|
throw new Error(`Track ${trackId} not found in ${filepath}`);
|
||||||
|
}
|
||||||
|
block.track = { ...block.track, ...updates };
|
||||||
|
|
||||||
|
// read file contents
|
||||||
|
let content = await fs.readFile(absPath(filepath), 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const yaml = stringifyYaml(block.track).trimEnd();
|
||||||
|
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||||
|
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||||
|
content = lines.join('\n');
|
||||||
|
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire YAML of a track block on disk with a new string.
|
||||||
|
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim —
|
||||||
|
* used when the user explicitly edits raw YAML in the modal.
|
||||||
|
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
|
||||||
|
* otherwise the write is rejected.
|
||||||
|
*/
|
||||||
|
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
|
||||||
|
return withFileLock(absPath(filePath), async () => {
|
||||||
|
const block = await fetch(filePath, trackId);
|
||||||
|
if (!block) {
|
||||||
|
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||||
|
}
|
||||||
|
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||||
|
}
|
||||||
|
if (parsed.data.trackId !== trackId) {
|
||||||
|
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const yamlLines = newYaml.trimEnd().split('\n');
|
||||||
|
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||||
|
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a track block and its sibling target region from the file.
|
||||||
|
*/
|
||||||
|
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
|
||||||
|
return withFileLock(absPath(filePath), async () => {
|
||||||
|
const block = await fetch(filePath, trackId);
|
||||||
|
if (!block) {
|
||||||
|
// Already gone — treat as success.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const openTag = `<!--track-target:${trackId}-->`;
|
||||||
|
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||||
|
|
||||||
|
// Find target region (may not exist)
|
||||||
|
let targetStart = -1;
|
||||||
|
let targetEnd = -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes(openTag)) { targetStart = i; }
|
||||||
|
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a list of [start, end] ranges to remove, sorted descending so
|
||||||
|
// indices stay valid as we splice.
|
||||||
|
const ranges: Array<[number, number]> = [];
|
||||||
|
ranges.push([block.fenceStart, block.fenceEnd]);
|
||||||
|
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
|
||||||
|
ranges.push([targetStart, targetEnd]);
|
||||||
|
}
|
||||||
|
ranges.sort((a, b) => b[0] - a[0]);
|
||||||
|
|
||||||
|
for (const [start, end] of ranges) {
|
||||||
|
lines.splice(start, end - start + 1);
|
||||||
|
// Also drop a trailing blank line if the removal left two in a row.
|
||||||
|
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
|
||||||
|
lines.splice(start, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||||
|
});
|
||||||
|
}
|
||||||
118
apps/x/packages/core/src/knowledge/track/routing.ts
Normal file
118
apps/x/packages/core/src/knowledge/track/routing.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import { trackBlock, PrefixLogger } from '@x/shared';
|
||||||
|
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||||
|
import container from '../../di/container.js';
|
||||||
|
import type { IModelConfigRepo } from '../../models/repo.js';
|
||||||
|
import { createProvider } from '../../models/models.js';
|
||||||
|
import { isSignedIn } from '../../account/account.js';
|
||||||
|
import { getGatewayProvider } from '../../models/gateway.js';
|
||||||
|
|
||||||
|
const log = new PrefixLogger('TrackRouting');
|
||||||
|
|
||||||
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
export interface ParsedTrack {
|
||||||
|
trackId: string;
|
||||||
|
filePath: string;
|
||||||
|
eventMatchCriteria: string;
|
||||||
|
instruction: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
|
||||||
|
|
||||||
|
You will receive an event (something that happened — an email, meeting, message, etc.) and a list of track blocks. Each track block has:
|
||||||
|
- trackId: an identifier (only unique within its file)
|
||||||
|
- filePath: the note file the track lives in
|
||||||
|
- eventMatchCriteria: a description of what kinds of signals are relevant to this track
|
||||||
|
|
||||||
|
Your job is to identify which track blocks MIGHT be relevant to this event.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
|
||||||
|
- Prefer false positives over false negatives. It is much better to include a track that turns out to be irrelevant than to miss one that was relevant.
|
||||||
|
- Only exclude tracks that are CLEARLY and OBVIOUSLY irrelevant to the event.
|
||||||
|
- Do not attempt to judge whether the event contains enough information to update the track. That is handled by a later stage.
|
||||||
|
- Return an empty list only if no tracks are relevant at all.
|
||||||
|
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
|
||||||
|
|
||||||
|
async function resolveModel() {
|
||||||
|
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||||
|
const config = await repo.getConfig();
|
||||||
|
const signedIn = await isSignedIn();
|
||||||
|
const provider = signedIn
|
||||||
|
? await getGatewayProvider()
|
||||||
|
: createProvider(config.provider);
|
||||||
|
const modelId = config.knowledgeGraphModel
|
||||||
|
|| (signedIn ? 'gpt-5.4' : config.model);
|
||||||
|
return provider.languageModel(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
|
||||||
|
const trackList = batch
|
||||||
|
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n eventMatchCriteria: ${t.eventMatchCriteria}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
return `## Event
|
||||||
|
|
||||||
|
Source: ${event.source}
|
||||||
|
Type: ${event.type}
|
||||||
|
Time: ${event.createdAt}
|
||||||
|
|
||||||
|
${event.payload}
|
||||||
|
|
||||||
|
## Track Blocks
|
||||||
|
|
||||||
|
${trackList}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackKey(trackId: string, filePath: string): string {
|
||||||
|
return `${filePath}::${trackId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findCandidates(
|
||||||
|
event: KnowledgeEvent,
|
||||||
|
allTracks: ParsedTrack[],
|
||||||
|
): Promise<ParsedTrack[]> {
|
||||||
|
// Short-circuit for targeted re-runs — skip LLM routing entirely
|
||||||
|
if (event.targetTrackId && event.targetFilePath) {
|
||||||
|
const target = allTracks.find(t =>
|
||||||
|
t.trackId === event.targetTrackId && t.filePath === event.targetFilePath
|
||||||
|
);
|
||||||
|
return target ? [target] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allTracks.filter(t =>
|
||||||
|
t.active && t.instruction && t.eventMatchCriteria
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
log.log(`No event-eligible tracks (none with eventMatchCriteria)`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
log.log(`Routing event ${event.id} against ${filtered.length} track(s)`);
|
||||||
|
|
||||||
|
const model = await resolveModel();
|
||||||
|
const candidateKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
|
||||||
|
const batch = filtered.slice(i, i + BATCH_SIZE);
|
||||||
|
try {
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model,
|
||||||
|
system: ROUTING_SYSTEM_PROMPT,
|
||||||
|
prompt: buildRoutingPrompt(event, batch),
|
||||||
|
schema: trackBlock.Pass1OutputSchema,
|
||||||
|
});
|
||||||
|
for (const c of object.candidates) {
|
||||||
|
candidateKeys.add(trackKey(c.trackId, c.filePath));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.log(`Routing batch ${i / BATCH_SIZE} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = filtered.filter(t => candidateKeys.has(trackKey(t.trackId, t.filePath)));
|
||||||
|
log.log(`Event ${event.id}: ${candidates.length} candidate(s) — ${candidates.map(c => `${c.trackId}@${c.filePath}`).join(', ') || '(none)'}`);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
316
apps/x/packages/core/src/knowledge/track/run-agent.ts
Normal file
316
apps/x/packages/core/src/knowledge/track/run-agent.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import z from 'zod';
|
||||||
|
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
|
||||||
|
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
|
||||||
|
import { WorkDir } from '../../config/config.js';
|
||||||
|
|
||||||
|
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date.
|
||||||
|
|
||||||
|
Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others — optimize for **information density and scannability**, not conversational prose.
|
||||||
|
|
||||||
|
# Background Mode
|
||||||
|
|
||||||
|
You are running as a scheduled or event-triggered background task — **there is no user present** to clarify, approve, or watch.
|
||||||
|
- Do NOT ask clarifying questions — make the most reasonable interpretation of the instruction and proceed.
|
||||||
|
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
|
||||||
|
- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line.
|
||||||
|
|
||||||
|
# Message Anatomy
|
||||||
|
|
||||||
|
Every run message has this shape:
|
||||||
|
|
||||||
|
Update track **<trackId>** in \`<filePath>\`.
|
||||||
|
|
||||||
|
**Time:** <localized datetime> (<timezone>)
|
||||||
|
|
||||||
|
**Instruction:**
|
||||||
|
<the user-authored track instruction — usually 1-3 sentences describing what to produce>
|
||||||
|
|
||||||
|
**Current content:**
|
||||||
|
<the existing contents of the target region, or "(empty — first run)">
|
||||||
|
|
||||||
|
Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`.
|
||||||
|
|
||||||
|
For **manual** runs, an optional trailing block may appear:
|
||||||
|
|
||||||
|
**Context:**
|
||||||
|
<extra one-run-only guidance — a backfill hint, a focus window, extra data>
|
||||||
|
|
||||||
|
Apply context for this run only — it is not a permanent edit to the instruction.
|
||||||
|
|
||||||
|
For **event-triggered** runs, a trailing block appears instead:
|
||||||
|
|
||||||
|
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
|
||||||
|
**Event match criteria for this track:** <from the track's YAML>
|
||||||
|
**Event payload:** <the event body — e.g., an email>
|
||||||
|
**Decision:** ... skip if not relevant ...
|
||||||
|
|
||||||
|
On event runs you are the Pass 2 judge — see "The No-Update Decision" below.
|
||||||
|
|
||||||
|
# What Good Output Looks Like
|
||||||
|
|
||||||
|
This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
|
||||||
|
|
||||||
|
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
|
||||||
|
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative — do not improvise a different layout.
|
||||||
|
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
|
||||||
|
- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know.
|
||||||
|
- **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately.
|
||||||
|
|
||||||
|
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events → \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
|
||||||
|
|
||||||
|
# Output Block Types
|
||||||
|
|
||||||
|
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
|
||||||
|
|
||||||
|
Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
|
||||||
|
|
||||||
|
## \`table\` — tabular data (JSON)
|
||||||
|
|
||||||
|
Use for: scoreboards, leaderboards, comparisons, multi-row status digests.
|
||||||
|
|
||||||
|
\`\`\`table
|
||||||
|
{
|
||||||
|
"title": "Top stories on Hacker News",
|
||||||
|
"columns": ["Rank", "Title", "Points", "Comments"],
|
||||||
|
"data": [
|
||||||
|
{"Rank": 1, "Title": "Show HN: ...", "Points": 842, "Comments": 312},
|
||||||
|
{"Rank": 2, "Title": "...", "Points": 530, "Comments": 144}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`columns\` (string[]), \`data\` (array of objects keyed by column name). Optional: \`title\`.
|
||||||
|
|
||||||
|
## \`chart\` — line / bar / pie chart (JSON)
|
||||||
|
|
||||||
|
Use for: time series, categorical breakdowns, share-of-total. Skip if a single sentence carries the meaning.
|
||||||
|
|
||||||
|
\`\`\`chart
|
||||||
|
{
|
||||||
|
"chart": "line",
|
||||||
|
"title": "USD/INR — last 7 days",
|
||||||
|
"x": "date",
|
||||||
|
"y": "rate",
|
||||||
|
"data": [
|
||||||
|
{"date": "2026-04-13", "rate": 83.41},
|
||||||
|
{"date": "2026-04-14", "rate": 83.38}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`chart\` ("line" | "bar" | "pie"), \`x\` (field name on each row), \`y\` (field name on each row), and **either** \`data\` (inline array of objects) **or** \`source\` (workspace path to a JSON-array file). Optional: \`title\`.
|
||||||
|
|
||||||
|
## \`mermaid\` — diagrams (raw Mermaid source)
|
||||||
|
|
||||||
|
Use for: relationship maps, flowcharts, sequence diagrams, gantt charts, mind maps.
|
||||||
|
|
||||||
|
\`\`\`mermaid
|
||||||
|
graph LR
|
||||||
|
A[Project Alpha] --> B[Sarah Chen]
|
||||||
|
A --> C[Acme Corp]
|
||||||
|
B --> D[Q3 Launch]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Body is plain Mermaid source — no JSON wrapper.
|
||||||
|
|
||||||
|
## \`calendar\` — list of events (JSON)
|
||||||
|
|
||||||
|
Use for: upcoming meetings, agenda digests, day/week views.
|
||||||
|
|
||||||
|
\`\`\`calendar
|
||||||
|
{
|
||||||
|
"title": "Today",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"summary": "1:1 with Sarah",
|
||||||
|
"start": {"dateTime": "2026-04-20T10:00:00-07:00"},
|
||||||
|
"end": {"dateTime": "2026-04-20T10:30:00-07:00"},
|
||||||
|
"location": "Zoom",
|
||||||
|
"conferenceLink": "https://zoom.us/j/..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\`end\` (object with \`dateTime\` ISO string OR \`date\` "YYYY-MM-DD" for all-day), \`location\`, \`htmlLink\`, \`conferenceLink\`, \`source\`. Optional top-level: \`title\`, \`showJoinButton\` (bool).
|
||||||
|
|
||||||
|
## \`email\` — single email or thread digest (JSON)
|
||||||
|
|
||||||
|
Use for: surfacing one important thread — latest message body, summary of prior context, optional draft reply.
|
||||||
|
|
||||||
|
\`\`\`email
|
||||||
|
{
|
||||||
|
"subject": "Q3 launch readiness",
|
||||||
|
"from": "sarah@acme.com",
|
||||||
|
"date": "2026-04-19T16:42:00Z",
|
||||||
|
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
|
||||||
|
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
|
||||||
|
|
||||||
|
For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time.
|
||||||
|
|
||||||
|
## \`image\` — single image (JSON)
|
||||||
|
|
||||||
|
Use for: charts, screenshots, photos you have a URL or workspace path for.
|
||||||
|
|
||||||
|
\`\`\`image
|
||||||
|
{
|
||||||
|
"src": "https://example.com/forecast.png",
|
||||||
|
"alt": "Weather forecast",
|
||||||
|
"caption": "Bay Area · April 20"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`src\` (URL or workspace path). Optional: \`alt\`, \`caption\`.
|
||||||
|
|
||||||
|
## \`embed\` — YouTube / Figma embed (JSON)
|
||||||
|
|
||||||
|
Use for: linking to a video or design that should render inline.
|
||||||
|
|
||||||
|
\`\`\`embed
|
||||||
|
{
|
||||||
|
"provider": "youtube",
|
||||||
|
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"caption": "Latest demo"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form.
|
||||||
|
|
||||||
|
## \`iframe\` — arbitrary embedded webpage (JSON)
|
||||||
|
|
||||||
|
Use for: live dashboards, status pages, trackers — anything that has its own webpage and benefits from being live, not snapshotted.
|
||||||
|
|
||||||
|
\`\`\`iframe
|
||||||
|
{
|
||||||
|
"url": "https://status.example.com",
|
||||||
|
"title": "Service status",
|
||||||
|
"height": 600
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`url\` (must be \`https://\` or \`http://localhost\`). Optional: \`title\`, \`caption\`, \`height\` (240–1600), \`allow\` (Permissions-Policy string).
|
||||||
|
|
||||||
|
## \`transcript\` — long transcript (JSON)
|
||||||
|
|
||||||
|
Use for: meeting transcripts, voice-note dumps — bodies that benefit from a collapsible UI.
|
||||||
|
|
||||||
|
\`\`\`transcript
|
||||||
|
{"transcript": "[00:00] Speaker A: Welcome everyone..."}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`transcript\` (string).
|
||||||
|
|
||||||
|
## \`prompt\` — starter Copilot prompt (YAML)
|
||||||
|
|
||||||
|
Use for: end-of-output "next step" cards. The user clicks **Run** and the chat sidebar opens with the underlying instruction submitted to Copilot, with this note attached as a file mention.
|
||||||
|
|
||||||
|
\`\`\`prompt
|
||||||
|
label: Draft replies to today's emails
|
||||||
|
instruction: |
|
||||||
|
For each unanswered email in the digest above, draft a 2-line reply
|
||||||
|
in my voice and present them as a checklist for me to approve.
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
|
||||||
|
|
||||||
|
# Interpreting the Instruction
|
||||||
|
|
||||||
|
The instruction was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
|
||||||
|
- "Top 5" is a target — fewer is acceptable if that's all that exists.
|
||||||
|
- "Current" means as of now (use the **Time** block).
|
||||||
|
- Unspecified units → standard for the domain (USD for US markets, metric for scientific, the user's locale if inferable from the timezone).
|
||||||
|
- Unspecified sources → your best reliable source (web-search for public data, workspace for user data).
|
||||||
|
|
||||||
|
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") — these are decoration.
|
||||||
|
|
||||||
|
# Current Content Handling
|
||||||
|
|
||||||
|
The **Current content** block shows what lives in the target region right now. Three cases:
|
||||||
|
|
||||||
|
1. **"(empty — first run)"** — produce the content from scratch.
|
||||||
|
2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep.
|
||||||
|
3. **Content that does NOT match the instruction's format** — the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch.
|
||||||
|
|
||||||
|
You always write a **complete** replacement, not a diff.
|
||||||
|
|
||||||
|
# The No-Update Decision
|
||||||
|
|
||||||
|
You may finish a run without calling \`update-track-content\`. Two legitimate cases:
|
||||||
|
|
||||||
|
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update.
|
||||||
|
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically.
|
||||||
|
|
||||||
|
When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*.
|
||||||
|
|
||||||
|
# Writing the Result
|
||||||
|
|
||||||
|
Call \`update-track-content\` **at most once per run**:
|
||||||
|
- Pass \`filePath\` and \`trackId\` exactly as given in the message.
|
||||||
|
- Pass the **complete** new content as \`content\` — the entire replacement for the target region.
|
||||||
|
- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those.
|
||||||
|
- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only.
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
You have the full workspace toolkit. Quick reference for common cases:
|
||||||
|
|
||||||
|
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
|
||||||
|
- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data.
|
||||||
|
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
|
||||||
|
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
|
||||||
|
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
|
||||||
|
|
||||||
|
# The Knowledge Graph
|
||||||
|
|
||||||
|
The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organized into:
|
||||||
|
- **People/** — individuals
|
||||||
|
- **Organizations/** — companies
|
||||||
|
- **Projects/** — initiatives
|
||||||
|
- **Topics/** — recurring themes
|
||||||
|
|
||||||
|
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when an instruction references emails, meetings, or calendar events.
|
||||||
|
|
||||||
|
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
|
||||||
|
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
|
||||||
|
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\`
|
||||||
|
- \`workspace-readdir("gmail_sync/")\`
|
||||||
|
|
||||||
|
# Failure & Fallback
|
||||||
|
|
||||||
|
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
|
||||||
|
- Do **not** fabricate or speculate.
|
||||||
|
- Do **not** write partial or placeholder content into the target region — leave existing content intact by not calling \`update-track-content\`.
|
||||||
|
- Explain the failure in the summary line.
|
||||||
|
|
||||||
|
# Final Summary
|
||||||
|
|
||||||
|
End your response with **one line** (1-2 short sentences). The system stores this as \`lastRunSummary\` and surfaces it in the UI.
|
||||||
|
|
||||||
|
State the action and the substance. Good examples:
|
||||||
|
- "Updated — 3 new HN stories, top is 'Show HN: …' at 842 pts."
|
||||||
|
- "Updated — USD/INR 83.42 as of 14:05 IST."
|
||||||
|
- "No change — status page shows all operational."
|
||||||
|
- "Skipped — event was a calendar invite unrelated to Q3 planning."
|
||||||
|
- "Failed — web-search returned no results for the query."
|
||||||
|
|
||||||
|
Avoid: "I updated the track.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function buildTrackRunAgent(): z.infer<typeof Agent> {
|
||||||
|
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||||
|
for (const name of Object.keys(BuiltinTools)) {
|
||||||
|
if (name === 'executeCommand') continue;
|
||||||
|
tools[name] = { type: 'builtin', name };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'track-run',
|
||||||
|
description: 'Background agent that updates track block content',
|
||||||
|
instructions: TRACK_RUN_INSTRUCTIONS,
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
}
|
||||||
168
apps/x/packages/core/src/knowledge/track/runner.ts
Normal file
168
apps/x/packages/core/src/knowledge/track/runner.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import z from 'zod';
|
||||||
|
import { fetchAll, updateTrackBlock } from './fileops.js';
|
||||||
|
import { createRun, createMessage } from '../../runs/runs.js';
|
||||||
|
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||||
|
import { trackBus } from './bus.js';
|
||||||
|
import type { TrackStateSchema } from './types.js';
|
||||||
|
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
|
||||||
|
|
||||||
|
export interface TrackUpdateResult {
|
||||||
|
trackId: string;
|
||||||
|
runId: string | null;
|
||||||
|
action: 'replace' | 'no_update';
|
||||||
|
contentBefore: string | null;
|
||||||
|
contentAfter: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent run
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildMessage(
|
||||||
|
filePath: string,
|
||||||
|
track: z.infer<typeof TrackStateSchema>,
|
||||||
|
trigger: 'manual' | 'timed' | 'event',
|
||||||
|
context?: string,
|
||||||
|
): string {
|
||||||
|
const now = new Date();
|
||||||
|
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
|
||||||
|
|
||||||
|
**Time:** ${localNow} (${tz})
|
||||||
|
|
||||||
|
**Instruction:**
|
||||||
|
${track.track.instruction}
|
||||||
|
|
||||||
|
**Current content:**
|
||||||
|
${track.content || '(empty — first run)'}
|
||||||
|
|
||||||
|
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
|
||||||
|
|
||||||
|
if (trigger === 'event') {
|
||||||
|
msg += `
|
||||||
|
|
||||||
|
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
|
||||||
|
|
||||||
|
**Event match criteria for this track:**
|
||||||
|
${track.track.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)'}
|
||||||
|
|
||||||
|
**Event payload:**
|
||||||
|
${context ?? '(no 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.`;
|
||||||
|
} else if (context) {
|
||||||
|
msg += `\n\n**Context:**\n${context}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Concurrency guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const runningTracks = new Set<string>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an update for a specific track block.
|
||||||
|
* Can be called by any trigger system (manual, cron, event matching).
|
||||||
|
*/
|
||||||
|
export async function triggerTrackUpdate(
|
||||||
|
trackId: string,
|
||||||
|
filePath: string,
|
||||||
|
context?: string,
|
||||||
|
trigger: 'manual' | 'timed' | 'event' = 'manual',
|
||||||
|
): Promise<TrackUpdateResult> {
|
||||||
|
const key = `${trackId}:${filePath}`;
|
||||||
|
const logger = new PrefixLogger('track:runner');
|
||||||
|
logger.log('triggering track update', trackId, filePath, trigger, context);
|
||||||
|
if (runningTracks.has(key)) {
|
||||||
|
logger.log('skipping, already running');
|
||||||
|
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Already running' };
|
||||||
|
}
|
||||||
|
runningTracks.add(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tracks = await fetchAll(filePath);
|
||||||
|
logger.log('fetched tracks from file', tracks);
|
||||||
|
const track = tracks.find(t => t.track.trackId === trackId);
|
||||||
|
if (!track) {
|
||||||
|
logger.log('track not found', trackId, filePath, trigger, context);
|
||||||
|
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentBefore = track.content;
|
||||||
|
|
||||||
|
// Emit start event — runId is set after agent run is created
|
||||||
|
const agentRun = await createRun({ agentId: 'track-run' });
|
||||||
|
|
||||||
|
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
||||||
|
// the scheduler's next poll won't re-trigger this track.
|
||||||
|
await updateTrackBlock(filePath, trackId, {
|
||||||
|
lastRunAt: new Date().toISOString(),
|
||||||
|
lastRunId: agentRun.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trackBus.publish({
|
||||||
|
type: 'track_run_start',
|
||||||
|
trackId,
|
||||||
|
filePath,
|
||||||
|
trigger,
|
||||||
|
runId: agentRun.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMessage(agentRun.id, buildMessage(filePath, track, trigger, context));
|
||||||
|
await waitForRunCompletion(agentRun.id);
|
||||||
|
const summary = await extractAgentResponse(agentRun.id);
|
||||||
|
|
||||||
|
const updatedTracks = await fetchAll(filePath);
|
||||||
|
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
|
||||||
|
const didUpdate = contentAfter !== contentBefore;
|
||||||
|
|
||||||
|
// Update summary on completion
|
||||||
|
await updateTrackBlock(filePath, trackId, {
|
||||||
|
lastRunSummary: summary ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trackBus.publish({
|
||||||
|
type: 'track_run_complete',
|
||||||
|
trackId,
|
||||||
|
filePath,
|
||||||
|
runId: agentRun.id,
|
||||||
|
summary: summary ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackId,
|
||||||
|
runId: agentRun.id,
|
||||||
|
action: didUpdate ? 'replace' : 'no_update',
|
||||||
|
contentBefore: contentBefore ?? null,
|
||||||
|
contentAfter: contentAfter ?? null,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
await trackBus.publish({
|
||||||
|
type: 'track_run_complete',
|
||||||
|
trackId,
|
||||||
|
filePath,
|
||||||
|
runId: agentRun.id,
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runningTracks.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/x/packages/core/src/knowledge/track/schedule-utils.ts
Normal file
63
apps/x/packages/core/src/knowledge/track/schedule-utils.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
import type { TrackSchedule } from '@x/shared/dist/track-block.js';
|
||||||
|
|
||||||
|
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a scheduled track is due to run.
|
||||||
|
* All schedule types enforce a 2-minute grace period — if the scheduled time
|
||||||
|
* was more than 2 minutes ago, it's considered a miss and skipped.
|
||||||
|
*/
|
||||||
|
export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string | null): boolean {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (schedule.type) {
|
||||||
|
case 'cron': {
|
||||||
|
if (!lastRunAt) return true; // Never ran — immediately due
|
||||||
|
try {
|
||||||
|
// Find the MOST RECENT occurrence at-or-before `now`, not the
|
||||||
|
// occurrence right after lastRunAt. If lastRunAt is old, that
|
||||||
|
// occurrence would be ancient too and always fall outside the
|
||||||
|
// grace window, blocking every future fire.
|
||||||
|
const interval = CronExpressionParser.parse(schedule.expression, {
|
||||||
|
currentDate: now,
|
||||||
|
});
|
||||||
|
const prevRun = interval.prev().toDate();
|
||||||
|
|
||||||
|
// Already ran at-or-after this occurrence → skip.
|
||||||
|
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
|
||||||
|
|
||||||
|
// Within grace → fire. Outside grace → missed, skip.
|
||||||
|
return now.getTime() <= prevRun.getTime() + GRACE_MS;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'window': {
|
||||||
|
// Time-of-day filter (applies regardless of lastRunAt state).
|
||||||
|
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
||||||
|
const startMinutes = startHour * 60 + startMin;
|
||||||
|
const endMinutes = endHour * 60 + endMin;
|
||||||
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
|
||||||
|
|
||||||
|
if (!lastRunAt) return true;
|
||||||
|
try {
|
||||||
|
const interval = CronExpressionParser.parse(schedule.cron, {
|
||||||
|
currentDate: now,
|
||||||
|
});
|
||||||
|
const prevRun = interval.prev().toDate();
|
||||||
|
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
|
||||||
|
return now.getTime() <= prevRun.getTime() + GRACE_MS;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'once': {
|
||||||
|
if (lastRunAt) return false; // Already ran
|
||||||
|
const runAt = new Date(schedule.runAt);
|
||||||
|
return now >= runAt && now.getTime() <= runAt.getTime() + GRACE_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/x/packages/core/src/knowledge/track/scheduler.ts
Normal file
66
apps/x/packages/core/src/knowledge/track/scheduler.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { PrefixLogger } from '@x/shared';
|
||||||
|
import * as workspace from '../../workspace/workspace.js';
|
||||||
|
import { fetchAll } from './fileops.js';
|
||||||
|
import { triggerTrackUpdate } from './runner.js';
|
||||||
|
import { isTrackScheduleDue } from './schedule-utils.js';
|
||||||
|
|
||||||
|
const log = new PrefixLogger('TrackScheduler');
|
||||||
|
const POLL_INTERVAL_MS = 15_000; // 15 seconds
|
||||||
|
|
||||||
|
async function listKnowledgeMarkdownFiles(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await workspace.readdir('knowledge', { recursive: true });
|
||||||
|
return entries
|
||||||
|
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
|
||||||
|
.map(e => e.path.replace(/^knowledge\//, ''));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processScheduledTracks(): Promise<void> {
|
||||||
|
const relativePaths = await listKnowledgeMarkdownFiles();
|
||||||
|
log.log(`Scanning ${relativePaths.length} markdown files`);
|
||||||
|
|
||||||
|
for (const relativePath of relativePaths) {
|
||||||
|
let tracks;
|
||||||
|
try {
|
||||||
|
tracks = await fetchAll(relativePath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trackState of tracks) {
|
||||||
|
const { track } = trackState;
|
||||||
|
if (!track.active) continue;
|
||||||
|
if (!track.schedule) continue;
|
||||||
|
|
||||||
|
const due = isTrackScheduleDue(track.schedule, track.lastRunAt ?? null);
|
||||||
|
log.log(`Track "${track.trackId}" in ${relativePath}: schedule=${track.schedule.type}, lastRunAt=${track.lastRunAt ?? 'never'}, due=${due}`);
|
||||||
|
|
||||||
|
if (due) {
|
||||||
|
log.log(`Triggering "${track.trackId}" in ${relativePath}`);
|
||||||
|
triggerTrackUpdate(track.trackId, relativePath, undefined, 'timed').catch(err => {
|
||||||
|
log.log(`Error running ${track.trackId}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function init(): Promise<void> {
|
||||||
|
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await processScheduledTracks();
|
||||||
|
|
||||||
|
// Periodic polling
|
||||||
|
while (true) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
try {
|
||||||
|
await processScheduledTracks();
|
||||||
|
} catch (error) {
|
||||||
|
log.log('Error in main loop:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/x/packages/core/src/knowledge/track/types.ts
Normal file
9
apps/x/packages/core/src/knowledge/track/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
|
||||||
|
|
||||||
|
export const TrackStateSchema = z.object({
|
||||||
|
track: TrackBlockSchema,
|
||||||
|
fenceStart: z.number(),
|
||||||
|
fenceEnd: z.number(),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
606
apps/x/packages/core/src/local-sites/server.ts
Normal file
606
apps/x/packages/core/src/local-sites/server.ts
Normal file
|
|
@ -0,0 +1,606 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import chokidar, { type FSWatcher } from 'chokidar';
|
||||||
|
import express from 'express';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import { LOCAL_SITE_SCAFFOLD } from './templates.js';
|
||||||
|
|
||||||
|
export const LOCAL_SITES_PORT = 3210;
|
||||||
|
export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
|
||||||
|
|
||||||
|
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
|
||||||
|
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
||||||
|
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
|
||||||
|
const SITE_RELOAD_MESSAGE = 'rowboat:site-changed';
|
||||||
|
const SITE_EVENTS_PATH = '__rowboat_events';
|
||||||
|
const SITE_RELOAD_DEBOUNCE_MS = 140;
|
||||||
|
const SITE_EVENTS_RETRY_MS = 1000;
|
||||||
|
const SITE_EVENTS_HEARTBEAT_MS = 15000;
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
'.css',
|
||||||
|
'.html',
|
||||||
|
'.js',
|
||||||
|
'.json',
|
||||||
|
'.map',
|
||||||
|
'.mjs',
|
||||||
|
'.svg',
|
||||||
|
'.txt',
|
||||||
|
'.xml',
|
||||||
|
]);
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.js': 'application/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
'.mjs': 'application/javascript; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.wasm': 'application/wasm',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.xml': 'application/xml; charset=utf-8',
|
||||||
|
};
|
||||||
|
const IFRAME_AUTOSIZE_BOOTSTRAP = String.raw`<script>
|
||||||
|
(() => {
|
||||||
|
const SITE_CHANGED_MESSAGE = '__ROWBOAT_SITE_CHANGED_MESSAGE__';
|
||||||
|
const SITE_EVENTS_PATH = '__ROWBOAT_SITE_EVENTS_PATH__';
|
||||||
|
let reloadRequested = false;
|
||||||
|
let reloadSource = null;
|
||||||
|
|
||||||
|
const getSiteSlug = () => {
|
||||||
|
const match = window.location.pathname.match(/^\/sites\/([^/]+)/i);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReload = () => {
|
||||||
|
if (reloadRequested) return;
|
||||||
|
reloadRequested = true;
|
||||||
|
try {
|
||||||
|
reloadSource?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close failures
|
||||||
|
}
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 80);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectLiveReload = () => {
|
||||||
|
const siteSlug = getSiteSlug();
|
||||||
|
if (!siteSlug || typeof EventSource === 'undefined') return;
|
||||||
|
|
||||||
|
const streamUrl = new URL('/sites/' + encodeURIComponent(siteSlug) + '/' + SITE_EVENTS_PATH, window.location.origin);
|
||||||
|
const source = new EventSource(streamUrl.toString());
|
||||||
|
reloadSource = source;
|
||||||
|
|
||||||
|
source.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
if (payload?.type === SITE_CHANGED_MESSAGE) {
|
||||||
|
scheduleReload();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payloads
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
try {
|
||||||
|
source.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close failures
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
connectLiveReload();
|
||||||
|
|
||||||
|
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
|
||||||
|
|
||||||
|
const MESSAGE_TYPE = '__ROWBOAT_IFRAME_HEIGHT_MESSAGE__';
|
||||||
|
const MIN_HEIGHT = 240;
|
||||||
|
let animationFrameId = 0;
|
||||||
|
let lastHeight = 0;
|
||||||
|
|
||||||
|
const applyEmbeddedStyles = () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (root) root.style.overflowY = 'hidden';
|
||||||
|
if (document.body) document.body.style.overflowY = 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureHeight = () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
return Math.max(
|
||||||
|
root?.scrollHeight ?? 0,
|
||||||
|
root?.offsetHeight ?? 0,
|
||||||
|
root?.clientHeight ?? 0,
|
||||||
|
body?.scrollHeight ?? 0,
|
||||||
|
body?.offsetHeight ?? 0,
|
||||||
|
body?.clientHeight ?? 0,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishHeight = () => {
|
||||||
|
animationFrameId = 0;
|
||||||
|
applyEmbeddedStyles();
|
||||||
|
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
|
||||||
|
if (Math.abs(nextHeight - lastHeight) < 2) return;
|
||||||
|
lastHeight = nextHeight;
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: MESSAGE_TYPE,
|
||||||
|
height: nextHeight,
|
||||||
|
href: window.location.href,
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePublish = () => {
|
||||||
|
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = requestAnimationFrame(publishHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = typeof ResizeObserver !== 'undefined'
|
||||||
|
? new ResizeObserver(schedulePublish)
|
||||||
|
: null;
|
||||||
|
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
|
||||||
|
if (resizeObserver && document.body) resizeObserver.observe(document.body);
|
||||||
|
|
||||||
|
const mutationObserver = new MutationObserver(schedulePublish);
|
||||||
|
if (document.documentElement) {
|
||||||
|
mutationObserver.observe(document.documentElement, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
attributes: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', schedulePublish);
|
||||||
|
window.addEventListener('resize', schedulePublish);
|
||||||
|
|
||||||
|
if (document.fonts?.addEventListener) {
|
||||||
|
document.fonts.addEventListener('loadingdone', schedulePublish);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const delay of [0, 50, 150, 300, 600, 1200]) {
|
||||||
|
setTimeout(schedulePublish, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePublish();
|
||||||
|
})();
|
||||||
|
</script>`;
|
||||||
|
|
||||||
|
let localSitesServer: Server | null = null;
|
||||||
|
let startPromise: Promise<void> | null = null;
|
||||||
|
let localSitesWatcher: FSWatcher | null = null;
|
||||||
|
const siteEventClients = new Map<string, Set<express.Response>>();
|
||||||
|
const siteReloadTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
function isSafeSiteSlug(siteSlug: string): boolean {
|
||||||
|
return SITE_SLUG_RE.test(siteSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSiteDir(siteSlug: string): string | null {
|
||||||
|
if (!isSafeSiteSlug(siteSlug)) return null;
|
||||||
|
return path.join(LOCAL_SITES_DIR, siteSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestedPath(siteDir: string, requestPath: string): string | null {
|
||||||
|
const candidate = requestPath === '/' ? '/index.html' : requestPath;
|
||||||
|
const normalized = path.posix.normalize(candidate);
|
||||||
|
const relativePath = normalized.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || relativePath.includes('\0')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(siteDir, relativePath);
|
||||||
|
if (!absolutePath.startsWith(siteDir + path.sep) && absolutePath !== siteDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPath(req: express.Request): string {
|
||||||
|
const rawPath = req.url.split('?')[0] || '/';
|
||||||
|
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalSites(): Array<{ slug: string; url: string }> {
|
||||||
|
if (!fs.existsSync(LOCAL_SITES_DIR)) return [];
|
||||||
|
|
||||||
|
return fs.readdirSync(LOCAL_SITES_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && isSafeSiteSlug(entry.name))
|
||||||
|
.map((entry) => ({
|
||||||
|
slug: entry.name,
|
||||||
|
url: `${LOCAL_SITES_BASE_URL}/sites/${entry.name}/`,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
|
||||||
|
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIfMissing(filePath: string, content: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsp.access(filePath);
|
||||||
|
} catch {
|
||||||
|
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fsp.writeFile(filePath, content, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLocalSiteScaffold(): Promise<void> {
|
||||||
|
await fsp.mkdir(LOCAL_SITES_DIR, { recursive: true });
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(LOCAL_SITE_SCAFFOLD).map(([relativePath, content]) =>
|
||||||
|
writeIfMissing(path.join(LOCAL_SITES_DIR, relativePath), content),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectIframeAutosizeBootstrap(html: string): string {
|
||||||
|
const bootstrap = IFRAME_AUTOSIZE_BOOTSTRAP
|
||||||
|
.replace('__ROWBOAT_IFRAME_HEIGHT_MESSAGE__', IFRAME_HEIGHT_MESSAGE)
|
||||||
|
.replace('__ROWBOAT_SITE_CHANGED_MESSAGE__', SITE_RELOAD_MESSAGE)
|
||||||
|
.replace('__ROWBOAT_SITE_EVENTS_PATH__', SITE_EVENTS_PATH)
|
||||||
|
if (/<\/body>/i.test(html)) {
|
||||||
|
return html.replace(/<\/body>/i, `${bootstrap}\n</body>`)
|
||||||
|
}
|
||||||
|
return `${html}\n${bootstrap}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSiteSlugFromAbsolutePath(absolutePath: string): string | null {
|
||||||
|
const relativePath = path.relative(LOCAL_SITES_DIR, absolutePath);
|
||||||
|
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [siteSlug] = relativePath.split(path.sep);
|
||||||
|
return siteSlug && isSafeSiteSlug(siteSlug) ? siteSlug : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSiteEventClient(siteSlug: string, res: express.Response): void {
|
||||||
|
const clients = siteEventClients.get(siteSlug);
|
||||||
|
if (!clients) return;
|
||||||
|
clients.delete(res);
|
||||||
|
if (clients.size === 0) {
|
||||||
|
siteEventClients.delete(siteSlug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastSiteReload(siteSlug: string, changedPath: string): void {
|
||||||
|
const clients = siteEventClients.get(siteSlug);
|
||||||
|
if (!clients || clients.size === 0) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: SITE_RELOAD_MESSAGE,
|
||||||
|
siteSlug,
|
||||||
|
changedPath,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const res of Array.from(clients)) {
|
||||||
|
try {
|
||||||
|
res.write(`data: ${payload}\n\n`);
|
||||||
|
} catch {
|
||||||
|
removeSiteEventClient(siteSlug, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSiteReload(siteSlug: string, changedPath: string): void {
|
||||||
|
const existingTimer = siteReloadTimers.get(siteSlug);
|
||||||
|
if (existingTimer) {
|
||||||
|
clearTimeout(existingTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
siteReloadTimers.delete(siteSlug);
|
||||||
|
broadcastSiteReload(siteSlug, changedPath);
|
||||||
|
}, SITE_RELOAD_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
siteReloadTimers.set(siteSlug, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSiteWatcher(): Promise<void> {
|
||||||
|
if (localSitesWatcher) return;
|
||||||
|
|
||||||
|
const watcher = chokidar.watch(LOCAL_SITES_DIR, {
|
||||||
|
ignoreInitial: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 180,
|
||||||
|
pollInterval: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.on('all', (eventName, absolutePath) => {
|
||||||
|
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
|
||||||
|
|
||||||
|
const siteSlug = getSiteSlugFromAbsolutePath(absolutePath);
|
||||||
|
if (!siteSlug) return;
|
||||||
|
|
||||||
|
const siteRoot = path.join(LOCAL_SITES_DIR, siteSlug);
|
||||||
|
const relativePath = path.relative(siteRoot, absolutePath);
|
||||||
|
const normalizedPath = !relativePath || relativePath === '.'
|
||||||
|
? '.'
|
||||||
|
: relativePath.split(path.sep).join('/');
|
||||||
|
|
||||||
|
scheduleSiteReload(siteSlug, normalizedPath);
|
||||||
|
})
|
||||||
|
.on('error', (error: unknown) => {
|
||||||
|
console.error('[LocalSites] Watcher error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
localSitesWatcher = watcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSiteEventsRequest(req: express.Request, res: express.Response): void {
|
||||||
|
const siteSlugParam = req.params.siteSlug;
|
||||||
|
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
|
||||||
|
if (!siteSlug || !isSafeSiteSlug(siteSlug)) {
|
||||||
|
res.status(400).json({ error: 'Invalid site slug' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = siteEventClients.get(siteSlug) ?? new Set<express.Response>();
|
||||||
|
siteEventClients.set(siteSlug, clients);
|
||||||
|
clients.add(res);
|
||||||
|
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.flushHeaders?.();
|
||||||
|
res.write(`retry: ${SITE_EVENTS_RETRY_MS}\n`);
|
||||||
|
res.write(`event: ready\ndata: {"ok":true}\n\n`);
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(`: keepalive ${Date.now()}\n\n`);
|
||||||
|
} catch {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
removeSiteEventClient(siteSlug, res);
|
||||||
|
}
|
||||||
|
}, SITE_EVENTS_HEARTBEAT_MS);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
removeSiteEventClient(siteSlug, res);
|
||||||
|
};
|
||||||
|
|
||||||
|
req.on('close', cleanup);
|
||||||
|
res.on('close', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
|
||||||
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader('Content-Type', mimeType);
|
||||||
|
res.setHeader('Content-Length', String(stats.size));
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
|
||||||
|
if (method === 'HEAD') {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TEXT_EXTENSIONS.has(extension)) {
|
||||||
|
let text = await fsp.readFile(filePath, 'utf8');
|
||||||
|
if (extension === '.html') {
|
||||||
|
text = injectIframeAutosizeBootstrap(text);
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
|
||||||
|
res.end(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fsp.readFile(filePath);
|
||||||
|
res.end(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSiteResponse(req: express.Request, res: express.Response): Promise<void> {
|
||||||
|
const siteSlugParam = req.params.siteSlug;
|
||||||
|
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
|
||||||
|
const siteDir = siteSlug ? resolveSiteDir(siteSlug) : null;
|
||||||
|
if (!siteDir) {
|
||||||
|
res.status(400).json({ error: 'Invalid site slug' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(siteDir) || !fs.statSync(siteDir).isDirectory()) {
|
||||||
|
res.status(404).json({ error: 'Site not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realSitesDir = fs.realpathSync(LOCAL_SITES_DIR);
|
||||||
|
const realSiteDir = fs.realpathSync(siteDir);
|
||||||
|
if (!isPathInsideRoot(realSitesDir, realSiteDir)) {
|
||||||
|
res.status(403).json({ error: 'Site path escapes sites directory' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPath = resolveRequestedPath(siteDir, getRequestPath(req));
|
||||||
|
if (!requestedPath) {
|
||||||
|
res.status(400).json({ error: 'Invalid site path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedExt = path.extname(requestedPath);
|
||||||
|
if (fs.existsSync(requestedPath)) {
|
||||||
|
const stat = fs.statSync(requestedPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const indexPath = path.join(requestedPath, 'index.html');
|
||||||
|
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
||||||
|
const realIndexPath = fs.realpathSync(indexPath);
|
||||||
|
if (!isPathInsideRoot(realSiteDir, realIndexPath)) {
|
||||||
|
res.status(403).json({ error: 'Site path escapes root' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await respondWithFile(res, indexPath, req.method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
const realRequestedPath = fs.realpathSync(requestedPath);
|
||||||
|
if (!isPathInsideRoot(realSiteDir, realRequestedPath)) {
|
||||||
|
res.status(403).json({ error: 'Site path escapes root' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await respondWithFile(res, requestedPath, req.method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedExt) {
|
||||||
|
res.status(404).json({ error: 'Asset not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaFallback = path.join(siteDir, 'index.html');
|
||||||
|
if (!fs.existsSync(spaFallback) || !fs.statSync(spaFallback).isFile()) {
|
||||||
|
res.status(404).json({ error: 'Site entrypoint not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFallback = fs.realpathSync(spaFallback);
|
||||||
|
if (!isPathInsideRoot(realSiteDir, realFallback)) {
|
||||||
|
res.status(403).json({ error: 'Site path escapes root' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await respondWithFile(res, spaFallback, req.method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalSitesApp(): express.Express {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
baseUrl: LOCAL_SITES_BASE_URL,
|
||||||
|
sitesDir: LOCAL_SITES_DIR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sites', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
sites: listLocalSites(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(`/sites/:siteSlug/${SITE_EVENTS_PATH}`, (req, res) => {
|
||||||
|
handleSiteEventsRequest(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/sites/:siteSlug', (req, res) => {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendSiteResponse(req, res).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(): Promise<void> {
|
||||||
|
if (localSitesServer) return;
|
||||||
|
|
||||||
|
const app = createLocalSitesApp();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const server = app.listen(LOCAL_SITES_PORT, 'localhost', () => {
|
||||||
|
localSitesServer = server;
|
||||||
|
console.log('[LocalSites] Server starting.');
|
||||||
|
console.log(` Sites directory: ${LOCAL_SITES_DIR}`);
|
||||||
|
console.log(` Base URL: ${LOCAL_SITES_BASE_URL}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`Port ${LOCAL_SITES_PORT} is already in use.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function init(): Promise<void> {
|
||||||
|
if (localSitesServer) return;
|
||||||
|
if (startPromise) return startPromise;
|
||||||
|
|
||||||
|
startPromise = (async () => {
|
||||||
|
try {
|
||||||
|
await ensureLocalSiteScaffold();
|
||||||
|
await startSiteWatcher();
|
||||||
|
await startServer();
|
||||||
|
} catch (error) {
|
||||||
|
await shutdown();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
startPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdown(): Promise<void> {
|
||||||
|
const watcher = localSitesWatcher;
|
||||||
|
localSitesWatcher = null;
|
||||||
|
if (watcher) {
|
||||||
|
await watcher.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const timer of siteReloadTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
siteReloadTimers.clear();
|
||||||
|
|
||||||
|
for (const clients of siteEventClients.values()) {
|
||||||
|
for (const res of clients) {
|
||||||
|
try {
|
||||||
|
res.end();
|
||||||
|
} catch {
|
||||||
|
// ignore close failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
siteEventClients.clear();
|
||||||
|
|
||||||
|
const server = localSitesServer;
|
||||||
|
localSitesServer = null;
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
625
apps/x/packages/core/src/local-sites/templates.ts
Normal file
625
apps/x/packages/core/src/local-sites/templates.ts
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
export const LOCAL_SITE_SCAFFOLD: Record<string, string> = {
|
||||||
|
'README.md': `# Local Sites
|
||||||
|
|
||||||
|
Anything inside this folder is available at:
|
||||||
|
|
||||||
|
\`http://localhost:3210/sites/<slug>/\`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- \`sites/example-dashboard/\` -> \`http://localhost:3210/sites/example-dashboard/\`
|
||||||
|
- \`sites/team-ops/\` -> \`http://localhost:3210/sites/team-ops/\`
|
||||||
|
|
||||||
|
You can embed a local site in a note with:
|
||||||
|
|
||||||
|
\`\`\`iframe
|
||||||
|
{"url":"http://localhost:3210/sites/example-dashboard/","title":"Signal Deck","height":640,"caption":"Local dashboard served from sites/example-dashboard"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The app serves each site with SPA-friendly routing, so client-side routers work
|
||||||
|
- Local HTML pages auto-expand inside Rowboat iframe blocks to fit their content height
|
||||||
|
- Put an \`index.html\` file at the site root
|
||||||
|
- Remote APIs still need to allow browser requests from a local page
|
||||||
|
`,
|
||||||
|
'example-dashboard/index.html': `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Signal Deck</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient ambient-one"></div>
|
||||||
|
<div class="ambient ambient-two"></div>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Local iframe sample · external APIs</p>
|
||||||
|
<h1>Signal Deck</h1>
|
||||||
|
<p class="lede">
|
||||||
|
A locally-served dashboard designed to live inside a Rowboat note. It fetches
|
||||||
|
live signals from public APIs and stays readable at note width.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status" id="hero-status">Booting dashboard...</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="metric-grid" id="metric-grid"></section>
|
||||||
|
|
||||||
|
<section class="board">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-kicker">Hacker News</p>
|
||||||
|
<h2>Live headlines</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-chip">public API</span>
|
||||||
|
</div>
|
||||||
|
<div class="story-list" id="story-list"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-kicker">GitHub</p>
|
||||||
|
<h2>Repo pulse</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-chip">public API</span>
|
||||||
|
</div>
|
||||||
|
<div class="repo-list" id="repo-list"></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
'example-dashboard/styles.css': `:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #090816;
|
||||||
|
--panel: rgba(18, 16, 39, 0.88);
|
||||||
|
--panel-strong: rgba(26, 23, 54, 0.96);
|
||||||
|
--line: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #f5f7ff;
|
||||||
|
--muted: rgba(230, 235, 255, 0.68);
|
||||||
|
--cyan: #66e2ff;
|
||||||
|
--lime: #b7ff6a;
|
||||||
|
--amber: #ffcb6b;
|
||||||
|
--pink: #ff7ed1;
|
||||||
|
--shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(74, 51, 175, 0.28), transparent 34%),
|
||||||
|
linear-gradient(180deg, #0c0b1d 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto;
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(70px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-one {
|
||||||
|
top: -80px;
|
||||||
|
right: -40px;
|
||||||
|
background: rgba(102, 226, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-two {
|
||||||
|
bottom: -120px;
|
||||||
|
left: -60px;
|
||||||
|
background: rgba(255, 126, 209, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
position: relative;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.panel-kicker {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
max-width: 620px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(102, 226, 255, 0.18);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(14, 17, 32, 0.62);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card,
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 18px;
|
||||||
|
min-height: 152px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card::after,
|
||||||
|
.panel::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 40%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-detail {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-spark {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: end;
|
||||||
|
height: 40px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-spark span {
|
||||||
|
display: block;
|
||||||
|
border-radius: 999px 999px 3px 3px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-chip {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-list,
|
||||||
|
.repo-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-item,
|
||||||
|
.repo-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-rank {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-item a,
|
||||||
|
.repo-item a {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-item a:hover,
|
||||||
|
.repo-item a:hover {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-title,
|
||||||
|
.repo-name {
|
||||||
|
padding-right: 34px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-meta,
|
||||||
|
.repo-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pill,
|
||||||
|
.repo-pill {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-description {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 940px) {
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.shell {
|
||||||
|
padding: 22px 14px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.metric-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'example-dashboard/app.js': `const formatter = new Intl.NumberFormat('en-US', {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reposConfig = [
|
||||||
|
{
|
||||||
|
slug: 'rowboatlabs/rowboat',
|
||||||
|
label: 'Rowboat',
|
||||||
|
description: 'AI coworker with memory',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'openai/openai-cookbook',
|
||||||
|
label: 'OpenAI Cookbook',
|
||||||
|
description: 'Examples and guides for building with OpenAI APIs',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fallbackStories = [
|
||||||
|
{ id: 1, title: 'AI product launches keep getting more opinionated', score: 182, descendants: 49, by: 'analyst', url: '#' },
|
||||||
|
{ id: 2, title: 'Designing dashboards that can survive a narrow iframe', score: 141, descendants: 26, by: 'maker', url: '#' },
|
||||||
|
{ id: 3, title: 'Why local mini-apps inside notes are underrated', score: 119, descendants: 18, by: 'builder', url: '#' },
|
||||||
|
{ id: 4, title: 'Teams want live data in docs, not screenshots', score: 97, descendants: 14, by: 'operator', url: '#' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fallbackRepos = [
|
||||||
|
{ ...reposConfig[0], stars: 1280, forks: 144, issues: 28, url: 'https://github.com/rowboatlabs/rowboat' },
|
||||||
|
{ ...reposConfig[1], stars: 71600, forks: 11300, issues: 52, url: 'https://github.com/openai/openai-cookbook' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const metricGrid = document.getElementById('metric-grid');
|
||||||
|
const storyList = document.getElementById('story-list');
|
||||||
|
const repoList = document.getElementById('repo-list');
|
||||||
|
const heroStatus = document.getElementById('hero-status');
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Request failed with status ' + response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRepos() {
|
||||||
|
try {
|
||||||
|
const repos = await Promise.all(
|
||||||
|
reposConfig.map(async (repo) => {
|
||||||
|
const data = await fetchJson('https://api.github.com/repos/' + repo.slug);
|
||||||
|
return {
|
||||||
|
...repo,
|
||||||
|
stars: data.stargazers_count,
|
||||||
|
forks: data.forks_count,
|
||||||
|
issues: data.open_issues_count,
|
||||||
|
url: data.html_url,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return repos;
|
||||||
|
} catch {
|
||||||
|
return fallbackRepos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStories() {
|
||||||
|
try {
|
||||||
|
const ids = await fetchJson('https://hacker-news.firebaseio.com/v0/topstories.json');
|
||||||
|
const stories = await Promise.all(
|
||||||
|
ids.slice(0, 4).map((id) =>
|
||||||
|
fetchJson('https://hacker-news.firebaseio.com/v0/item/' + id + '.json'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return stories
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((story) => ({
|
||||||
|
id: story.id,
|
||||||
|
title: story.title,
|
||||||
|
score: story.score || 0,
|
||||||
|
descendants: story.descendants || 0,
|
||||||
|
by: story.by || 'unknown',
|
||||||
|
url: story.url || ('https://news.ycombinator.com/item?id=' + story.id),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return fallbackStories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricSpark(values) {
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const bars = values.map((value) => {
|
||||||
|
const height = Math.max(18, Math.round((value / max) * 40));
|
||||||
|
return '<span style="height:' + height + 'px"></span>';
|
||||||
|
});
|
||||||
|
return '<div class="metric-spark">' + bars.join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(repos, stories) {
|
||||||
|
const leadRepo = repos[0];
|
||||||
|
const companionRepo = repos[1];
|
||||||
|
const topStory = stories[0];
|
||||||
|
const averageScore = Math.round(
|
||||||
|
stories.reduce((sum, story) => sum + story.score, 0) / Math.max(stories.length, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
label: 'Rowboat stars',
|
||||||
|
value: formatter.format(leadRepo.stars),
|
||||||
|
detail: formatter.format(leadRepo.forks) + ' forks · ' + leadRepo.issues + ' open issues',
|
||||||
|
spark: [leadRepo.stars * 0.58, leadRepo.stars * 0.71, leadRepo.stars * 0.88, leadRepo.stars],
|
||||||
|
accent: 'var(--cyan)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cookbook stars',
|
||||||
|
value: formatter.format(companionRepo.stars),
|
||||||
|
detail: formatter.format(companionRepo.forks) + ' forks · ' + companionRepo.issues + ' open issues',
|
||||||
|
spark: [companionRepo.stars * 0.76, companionRepo.stars * 0.81, companionRepo.stars * 0.93, companionRepo.stars],
|
||||||
|
accent: 'var(--lime)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Top story score',
|
||||||
|
value: formatter.format(topStory.score),
|
||||||
|
detail: topStory.descendants + ' comments · by ' + topStory.by,
|
||||||
|
spark: stories.map((story) => story.score),
|
||||||
|
accent: 'var(--amber)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Average HN score',
|
||||||
|
value: formatter.format(averageScore),
|
||||||
|
detail: stories.length + ' live stories in this panel',
|
||||||
|
spark: stories.map((story) => story.descendants + 10),
|
||||||
|
accent: 'var(--pink)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
metricGrid.innerHTML = metrics
|
||||||
|
.map((metric) => (
|
||||||
|
'<article class="metric-card" style="box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 80px rgba(0,0,0,0.34), 0 0 0 1px color-mix(in srgb, ' + metric.accent + ' 16%, transparent);">' +
|
||||||
|
'<div class="metric-label">' + metric.label + '</div>' +
|
||||||
|
'<div class="metric-value">' + metric.value + '</div>' +
|
||||||
|
'<div class="metric-detail">' + metric.detail + '</div>' +
|
||||||
|
metricSpark(metric.spark) +
|
||||||
|
'</article>'
|
||||||
|
))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStories(stories) {
|
||||||
|
storyList.innerHTML = stories
|
||||||
|
.map((story, index) => (
|
||||||
|
'<article class="story-item">' +
|
||||||
|
'<div class="story-rank">0' + (index + 1) + '</div>' +
|
||||||
|
'<a class="story-title" href="' + story.url + '" target="_blank" rel="noreferrer">' + story.title + '</a>' +
|
||||||
|
'<div class="story-meta">' +
|
||||||
|
'<span class="story-pill">' + formatter.format(story.score) + ' pts</span>' +
|
||||||
|
'<span class="story-pill">' + story.descendants + ' comments</span>' +
|
||||||
|
'<span class="story-pill">by ' + story.by + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</article>'
|
||||||
|
))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRepos(repos) {
|
||||||
|
repoList.innerHTML = repos
|
||||||
|
.map((repo) => (
|
||||||
|
'<article class="repo-item">' +
|
||||||
|
'<a class="repo-name" href="' + repo.url + '" target="_blank" rel="noreferrer">' + repo.label + '</a>' +
|
||||||
|
'<p class="repo-description">' + repo.description + '</p>' +
|
||||||
|
'<div class="repo-meta">' +
|
||||||
|
'<span class="repo-pill">' + formatter.format(repo.stars) + ' stars</span>' +
|
||||||
|
'<span class="repo-pill">' + formatter.format(repo.forks) + ' forks</span>' +
|
||||||
|
'<span class="repo-pill">' + repo.issues + ' open issues</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</article>'
|
||||||
|
))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErrorState(message) {
|
||||||
|
metricGrid.innerHTML = '<div class="empty-state">' + message + '</div>';
|
||||||
|
storyList.innerHTML = '<div class="empty-state">No stories available.</div>';
|
||||||
|
repoList.innerHTML = '<div class="empty-state">No repositories available.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
heroStatus.textContent = 'Refreshing live signals...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [repos, stories] = await Promise.all([loadRepos(), loadStories()]);
|
||||||
|
|
||||||
|
if (!repos.length || !stories.length) {
|
||||||
|
renderErrorState('The sample site loaded, but the data sources returned no content.');
|
||||||
|
heroStatus.textContent = 'Loaded with empty data.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMetrics(repos, stories);
|
||||||
|
renderStories(stories);
|
||||||
|
renderRepos(repos);
|
||||||
|
|
||||||
|
heroStatus.textContent = 'Updated ' + new Date().toLocaleTimeString([], {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}) + ' · embedded from sites/example-dashboard';
|
||||||
|
} catch (error) {
|
||||||
|
renderErrorState('This site is running, but the live fetch failed. The local scaffold is still valid.');
|
||||||
|
heroStatus.textContent = error instanceof Error ? error.message : 'Refresh failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 120000);
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,18 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||||
import { getAccessToken } from '../auth/tokens.js';
|
import { getAccessToken } from '../auth/tokens.js';
|
||||||
import { API_URL } from '../config/env.js';
|
import { API_URL } from '../config/env.js';
|
||||||
|
|
||||||
|
const authedFetch: typeof fetch = async (input, init) => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
return fetch(input, { ...init, headers });
|
||||||
|
};
|
||||||
|
|
||||||
export async function getGatewayProvider(): Promise<ProviderV2> {
|
export async function getGatewayProvider(): Promise<ProviderV2> {
|
||||||
const accessToken = await getAccessToken();
|
|
||||||
return createOpenRouter({
|
return createOpenRouter({
|
||||||
baseURL: `${API_URL}/v1/llm`,
|
baseURL: `${API_URL}/v1/llm`,
|
||||||
apiKey: accessToken,
|
apiKey: 'managed-by-rowboat',
|
||||||
|
fetch: authedFetch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
import { waitForRunCompletion } from '../agents/utils.js';
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
loadState,
|
loadState,
|
||||||
|
|
@ -18,20 +18,6 @@ import { PREBUILT_AGENTS } from './types.js';
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000; // Check every minute which agents need to run
|
const CHECK_INTERVAL_MS = 60 * 1000; // Check every minute which agents need to run
|
||||||
const PREBUILT_DIR = path.join(WorkDir, 'pre-built');
|
const PREBUILT_DIR = path.join(WorkDir, 'pre-built');
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a run to complete by listening for run-processing-end event
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a pre-built agent by name
|
* Run a pre-built agent by name
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
import { IMessageQueue, UserMessageContentType, VoiceOutputMode } from "../application/lib/message-queue.js";
|
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||||
import { IRunsRepo } from "./repo.js";
|
import { IRunsRepo } from "./repo.js";
|
||||||
import { IAgentRuntime } from "../agents/runtime.js";
|
import { IAgentRuntime } from "../agents/runtime.js";
|
||||||
|
|
@ -19,9 +19,9 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
|
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled);
|
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
||||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||||
runtime.trigger(runId);
|
runtime.trigger(runId);
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/share
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
||||||
import { commitAll } from '../knowledge/version_history.js';
|
import { commitAll } from '../knowledge/version_history.js';
|
||||||
|
import { withFileLock } from '../knowledge/file-lock.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Path Utilities
|
// Path Utilities
|
||||||
|
|
@ -249,38 +250,42 @@ export async function writeFile(
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check expectedEtag if provided (conflict detection)
|
const result = await withFileLock(filePath, async () => {
|
||||||
if (opts?.expectedEtag) {
|
// Check expectedEtag if provided (conflict detection)
|
||||||
const existingStats = await fs.lstat(filePath);
|
if (opts?.expectedEtag) {
|
||||||
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
|
const existingStats = await fs.lstat(filePath);
|
||||||
if (existingEtag !== opts.expectedEtag) {
|
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
|
||||||
throw new Error('File was modified (ETag mismatch)');
|
if (existingEtag !== opts.expectedEtag) {
|
||||||
|
throw new Error('File was modified (ETag mismatch)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Convert data to buffer based on encoding
|
// Convert data to buffer based on encoding
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
if (encoding === 'utf8') {
|
if (encoding === 'utf8') {
|
||||||
buffer = Buffer.from(data, 'utf8');
|
buffer = Buffer.from(data, 'utf8');
|
||||||
} else if (encoding === 'base64') {
|
} else if (encoding === 'base64') {
|
||||||
buffer = Buffer.from(data, 'base64');
|
buffer = Buffer.from(data, 'base64');
|
||||||
} else {
|
} else {
|
||||||
// binary: assume data is base64-encoded
|
// binary: assume data is base64-encoded
|
||||||
buffer = Buffer.from(data, 'base64');
|
buffer = Buffer.from(data, 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atomic) {
|
if (atomic) {
|
||||||
// Atomic write: write to temp file, then rename
|
// Atomic write: write to temp file, then rename
|
||||||
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
|
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
|
||||||
await fs.writeFile(tempPath, buffer);
|
await fs.writeFile(tempPath, buffer);
|
||||||
await fs.rename(tempPath, filePath);
|
await fs.rename(tempPath, filePath);
|
||||||
} else {
|
} else {
|
||||||
await fs.writeFile(filePath, buffer);
|
await fs.writeFile(filePath, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await fs.lstat(filePath);
|
const stats = await fs.lstat(filePath);
|
||||||
const stat = statToSchema(stats, 'file');
|
const stat = statToSchema(stats, 'file');
|
||||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||||
|
|
||||||
|
return { stat, etag };
|
||||||
|
});
|
||||||
|
|
||||||
// Schedule a debounced version history commit for knowledge files
|
// Schedule a debounced version history commit for knowledge files
|
||||||
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
||||||
|
|
@ -289,8 +294,8 @@ export async function writeFile(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: relPath,
|
path: relPath,
|
||||||
stat,
|
stat: result.stat,
|
||||||
etag,
|
etag: result.etag,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const IFRAME_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||||
|
|
||||||
|
export function isAllowedIframeUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol === 'https:') return true;
|
||||||
|
if (parsed.protocol !== 'http:') return false;
|
||||||
|
return IFRAME_LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ImageBlockSchema = z.object({
|
export const ImageBlockSchema = z.object({
|
||||||
src: z.string(),
|
src: z.string(),
|
||||||
alt: z.string().optional(),
|
alt: z.string().optional(),
|
||||||
|
|
@ -16,6 +29,18 @@ export const EmbedBlockSchema = z.object({
|
||||||
|
|
||||||
export type EmbedBlock = z.infer<typeof EmbedBlockSchema>;
|
export type EmbedBlock = z.infer<typeof EmbedBlockSchema>;
|
||||||
|
|
||||||
|
export const IframeBlockSchema = z.object({
|
||||||
|
url: z.string().url().refine(isAllowedIframeUrl, {
|
||||||
|
message: 'Iframe URLs must use https:// or local http://localhost / 127.0.0.1.',
|
||||||
|
}),
|
||||||
|
title: z.string().optional(),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
height: z.number().int().min(240).max(1600).optional(),
|
||||||
|
allow: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IframeBlock = z.infer<typeof IframeBlockSchema>;
|
||||||
|
|
||||||
export const ChartBlockSchema = z.object({
|
export const ChartBlockSchema = z.object({
|
||||||
chart: z.enum(['line', 'bar', 'pie']),
|
chart: z.enum(['line', 'bar', 'pie']),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
|
@ -81,3 +106,11 @@ export const TranscriptBlockSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TranscriptBlock = z.infer<typeof TranscriptBlockSchema>;
|
export type TranscriptBlock = z.infer<typeof TranscriptBlockSchema>;
|
||||||
|
|
||||||
|
export const SuggestedTopicBlockSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SuggestedTopicBlock = z.infer<typeof SuggestedTopicBlockSchema>;
|
||||||
|
|
|
||||||
134
apps/x/packages/shared/src/browser-control.ts
Normal file
134
apps/x/packages/shared/src/browser-control.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const BrowserTabStateSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
canGoBack: z.boolean(),
|
||||||
|
canGoForward: z.boolean(),
|
||||||
|
loading: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BrowserStateSchema = z.object({
|
||||||
|
activeTabId: z.string().nullable(),
|
||||||
|
tabs: z.array(BrowserTabStateSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BrowserPageElementSchema = z.object({
|
||||||
|
index: z.number().int().positive(),
|
||||||
|
tagName: z.string(),
|
||||||
|
role: z.string().nullable(),
|
||||||
|
type: z.string().nullable(),
|
||||||
|
label: z.string().nullable(),
|
||||||
|
text: z.string().nullable(),
|
||||||
|
placeholder: z.string().nullable(),
|
||||||
|
href: z.string().nullable(),
|
||||||
|
disabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BrowserPageSnapshotSchema = z.object({
|
||||||
|
snapshotId: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
loading: z.boolean(),
|
||||||
|
text: z.string(),
|
||||||
|
elements: z.array(BrowserPageElementSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BrowserControlActionSchema = z.enum([
|
||||||
|
'open',
|
||||||
|
'get-state',
|
||||||
|
'new-tab',
|
||||||
|
'switch-tab',
|
||||||
|
'close-tab',
|
||||||
|
'navigate',
|
||||||
|
'back',
|
||||||
|
'forward',
|
||||||
|
'reload',
|
||||||
|
'read-page',
|
||||||
|
'click',
|
||||||
|
'type',
|
||||||
|
'press',
|
||||||
|
'scroll',
|
||||||
|
'wait',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BrowserElementTargetFields = {
|
||||||
|
index: z.number().int().positive().optional(),
|
||||||
|
selector: z.string().min(1).optional(),
|
||||||
|
snapshotId: z.string().optional(),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BrowserControlInputSchema = z.object({
|
||||||
|
action: BrowserControlActionSchema,
|
||||||
|
target: z.string().min(1).optional(),
|
||||||
|
tabId: z.string().min(1).optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
key: z.string().min(1).optional(),
|
||||||
|
direction: z.enum(['up', 'down']).optional(),
|
||||||
|
amount: z.number().int().positive().max(5000).optional(),
|
||||||
|
ms: z.number().int().positive().max(30000).optional(),
|
||||||
|
maxElements: z.number().int().positive().max(100).optional(),
|
||||||
|
maxTextLength: z.number().int().positive().max(20000).optional(),
|
||||||
|
...BrowserElementTargetFields,
|
||||||
|
}).strict().superRefine((value, ctx) => {
|
||||||
|
const needsElementTarget = value.action === 'click' || value.action === 'type';
|
||||||
|
const hasElementTarget = value.index !== undefined || value.selector !== undefined;
|
||||||
|
|
||||||
|
if ((value.action === 'switch-tab' || value.action === 'close-tab') && !value.tabId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['tabId'],
|
||||||
|
message: 'tabId is required for this action.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((value.action === 'navigate') && !value.target) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['target'],
|
||||||
|
message: 'target is required for navigate.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.action === 'type' && value.text === undefined) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['text'],
|
||||||
|
message: 'text is required for type.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.action === 'press' && !value.key) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['key'],
|
||||||
|
message: 'key is required for press.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsElementTarget && !hasElementTarget) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['index'],
|
||||||
|
message: 'Provide an element index or selector.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BrowserControlResultSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
action: BrowserControlActionSchema,
|
||||||
|
message: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
browser: BrowserStateSchema,
|
||||||
|
page: BrowserPageSnapshotSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
|
||||||
|
export type BrowserState = z.infer<typeof BrowserStateSchema>;
|
||||||
|
export type BrowserPageElement = z.infer<typeof BrowserPageElementSchema>;
|
||||||
|
export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>;
|
||||||
|
export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;
|
||||||
|
export type BrowserControlInput = z.infer<typeof BrowserControlInputSchema>;
|
||||||
|
export type BrowserControlResult = z.infer<typeof BrowserControlResultSchema>;
|
||||||
|
|
@ -9,6 +9,9 @@ export * as agentScheduleState from './agent-schedule-state.js';
|
||||||
export * as serviceEvents from './service-events.js'
|
export * as serviceEvents from './service-events.js'
|
||||||
export * as inlineTask from './inline-task.js';
|
export * as inlineTask from './inline-task.js';
|
||||||
export * as blocks from './blocks.js';
|
export * as blocks from './blocks.js';
|
||||||
|
export * as trackBlock from './track-block.js';
|
||||||
|
export * as promptBlock from './prompt-block.js';
|
||||||
export * as frontmatter from './frontmatter.js';
|
export * as frontmatter from './frontmatter.js';
|
||||||
export * as bases from './bases.js';
|
export * as bases from './bases.js';
|
||||||
|
export * as browserControl from './browser-control.js';
|
||||||
export { PrefixLogger };
|
export { PrefixLogger };
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ import { LlmModelConfig } from './models.js';
|
||||||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||||
import { ServiceEvent } from './service-events.js';
|
import { ServiceEvent } from './service-events.js';
|
||||||
|
import { TrackEvent } from './track-block.js';
|
||||||
import { UserMessageContent } from './message.js';
|
import { UserMessageContent } from './message.js';
|
||||||
import { RowboatApiConfig } from './rowboat-account.js';
|
import { RowboatApiConfig } from './rowboat-account.js';
|
||||||
import { ZListToolkitsResponse } from './composio.js';
|
import { ZListToolkitsResponse } from './composio.js';
|
||||||
|
import { BrowserStateSchema } from './browser-control.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Runtime Validation Schemas (Single Source of Truth)
|
// Runtime Validation Schemas (Single Source of Truth)
|
||||||
|
|
@ -135,6 +137,18 @@ const ipcSchemas = {
|
||||||
voiceInput: z.boolean().optional(),
|
voiceInput: z.boolean().optional(),
|
||||||
voiceOutput: z.enum(['summary', 'full']).optional(),
|
voiceOutput: z.enum(['summary', 'full']).optional(),
|
||||||
searchEnabled: z.boolean().optional(),
|
searchEnabled: z.boolean().optional(),
|
||||||
|
middlePaneContext: z.discriminatedUnion('kind', [
|
||||||
|
z.object({
|
||||||
|
kind: z.literal('note'),
|
||||||
|
path: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal('browser'),
|
||||||
|
url: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
}),
|
||||||
|
]).optional(),
|
||||||
}),
|
}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
messageId: z.string(),
|
messageId: z.string(),
|
||||||
|
|
@ -193,6 +207,10 @@ const ipcSchemas = {
|
||||||
req: ServiceEvent,
|
req: ServiceEvent,
|
||||||
res: z.null(),
|
res: z.null(),
|
||||||
},
|
},
|
||||||
|
'tracks:events': {
|
||||||
|
req: TrackEvent,
|
||||||
|
res: z.null(),
|
||||||
|
},
|
||||||
'models:list': {
|
'models:list': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
@ -560,6 +578,148 @@ const ipcSchemas = {
|
||||||
response: z.string().nullable(),
|
response: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Track channels
|
||||||
|
'track:run': {
|
||||||
|
req: z.object({
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'track:get': {
|
||||||
|
req: z.object({
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
// Fresh, authoritative YAML of the track block from disk.
|
||||||
|
// Renderer should use this for display/edit — never its Tiptap node attr.
|
||||||
|
yaml: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'track:update': {
|
||||||
|
req: z.object({
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
// Partial TrackBlock updates — merged into the block's YAML on disk.
|
||||||
|
// Backend is the sole writer; avoids races with scheduler/runner writes.
|
||||||
|
updates: z.record(z.string(), z.unknown()),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
yaml: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'track:replaceYaml': {
|
||||||
|
req: z.object({
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
yaml: z.string(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
yaml: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'track:delete': {
|
||||||
|
req: z.object({
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Embedded browser (WebContentsView) channels
|
||||||
|
'browser:setBounds': {
|
||||||
|
req: z.object({
|
||||||
|
x: z.number().int(),
|
||||||
|
y: z.number().int(),
|
||||||
|
width: z.number().int().nonnegative(),
|
||||||
|
height: z.number().int().nonnegative(),
|
||||||
|
}),
|
||||||
|
res: z.object({ ok: z.literal(true) }),
|
||||||
|
},
|
||||||
|
'browser:setVisible': {
|
||||||
|
req: z.object({ visible: z.boolean() }),
|
||||||
|
res: z.object({ ok: z.literal(true) }),
|
||||||
|
},
|
||||||
|
'browser:newTab': {
|
||||||
|
req: z.object({
|
||||||
|
url: z.string().min(1).refine(
|
||||||
|
(u) => {
|
||||||
|
const lower = u.trim().toLowerCase();
|
||||||
|
if (lower.startsWith('javascript:')) return false;
|
||||||
|
if (lower.startsWith('file://')) return false;
|
||||||
|
if (lower.startsWith('chrome://')) return false;
|
||||||
|
if (lower.startsWith('chrome-extension://')) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: 'Unsafe URL scheme' },
|
||||||
|
).optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
ok: z.boolean(),
|
||||||
|
tabId: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'browser:switchTab': {
|
||||||
|
req: z.object({ tabId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean() }),
|
||||||
|
},
|
||||||
|
'browser:closeTab': {
|
||||||
|
req: z.object({ tabId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean() }),
|
||||||
|
},
|
||||||
|
'browser:navigate': {
|
||||||
|
req: z.object({
|
||||||
|
url: z.string().min(1).refine(
|
||||||
|
(u) => {
|
||||||
|
const lower = u.trim().toLowerCase();
|
||||||
|
if (lower.startsWith('javascript:')) return false;
|
||||||
|
if (lower.startsWith('file://')) return false;
|
||||||
|
if (lower.startsWith('chrome://')) return false;
|
||||||
|
if (lower.startsWith('chrome-extension://')) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: 'Unsafe URL scheme' },
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
ok: z.boolean(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'browser:back': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({ ok: z.boolean() }),
|
||||||
|
},
|
||||||
|
'browser:forward': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({ ok: z.boolean() }),
|
||||||
|
},
|
||||||
|
'browser:reload': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({ ok: z.literal(true) }),
|
||||||
|
},
|
||||||
|
'browser:getState': {
|
||||||
|
req: z.null(),
|
||||||
|
res: BrowserStateSchema,
|
||||||
|
},
|
||||||
|
'browser:didUpdateState': {
|
||||||
|
req: BrowserStateSchema,
|
||||||
|
res: z.null(),
|
||||||
|
},
|
||||||
// Billing channels
|
// Billing channels
|
||||||
'billing:getInfo': {
|
'billing:getInfo': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
|
|
|
||||||
8
apps/x/packages/shared/src/prompt-block.ts
Normal file
8
apps/x/packages/shared/src/prompt-block.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const PromptBlockSchema = z.object({
|
||||||
|
label: z.string().min(1).describe('Short title shown on the card'),
|
||||||
|
instruction: z.string().min(1).describe('Full prompt sent to Copilot when Run is clicked'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PromptBlock = z.infer<typeof PromptBlockSchema>;
|
||||||
87
apps/x/packages/shared/src/track-block.ts
Normal file
87
apps/x/packages/shared/src/track-block.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const TrackScheduleSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('cron').describe('Fires at exact cron times'),
|
||||||
|
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
|
||||||
|
}).describe('Recurring at exact times'),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('window').describe('Fires at most once per cron occurrence, only within a time-of-day window'),
|
||||||
|
cron: z.string().describe('5-field cron expression, quoted'),
|
||||||
|
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
|
||||||
|
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
|
||||||
|
}).describe('Recurring within a time-of-day window'),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('once').describe('Fires once and never again'),
|
||||||
|
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
|
||||||
|
}).describe('One-shot future run'),
|
||||||
|
]).describe('Optional schedule. Omit entirely for manual-only tracks.');
|
||||||
|
|
||||||
|
export type TrackSchedule = z.infer<typeof TrackScheduleSchema>;
|
||||||
|
|
||||||
|
export const TrackBlockSchema = z.object({
|
||||||
|
trackId: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
|
||||||
|
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
|
||||||
|
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
|
||||||
|
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||||
|
schedule: TrackScheduleSchema.optional(),
|
||||||
|
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||||
|
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||||
|
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Knowledge events (event-driven track triggering pipeline)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const KnowledgeEventSchema = z.object({
|
||||||
|
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||||
|
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||||
|
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||||
|
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||||
|
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||||
|
targetTrackId: z.string().optional().describe('If set, skip routing and target this track directly (used for re-runs)'),
|
||||||
|
targetFilePath: z.string().optional(),
|
||||||
|
// Enriched on move from pending/ to done/
|
||||||
|
processedAt: z.string().optional(),
|
||||||
|
candidates: z.array(z.object({
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
runIds: z.array(z.string()).optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
||||||
|
|
||||||
|
export const Pass1OutputSchema = z.object({
|
||||||
|
candidates: z.array(z.object({
|
||||||
|
trackId: z.string().describe('The track block identifier'),
|
||||||
|
filePath: z.string().describe('The note file path the track lives in'),
|
||||||
|
})).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||||
|
|
||||||
|
// Track bus events
|
||||||
|
export const TrackRunStartEvent = z.object({
|
||||||
|
type: z.literal('track_run_start'),
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
trigger: z.enum(['timed', 'manual', 'event']),
|
||||||
|
runId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TrackRunCompleteEvent = z.object({
|
||||||
|
type: z.literal('track_run_complete'),
|
||||||
|
trackId: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
runId: z.string(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
|
||||||
|
|
||||||
|
export type TrackBlock = z.infer<typeof TrackBlockSchema>;
|
||||||
|
export type TrackEventType = z.infer<typeof TrackEvent>;
|
||||||
17
apps/x/pnpm-lock.yaml
generated
17
apps/x/pnpm-lock.yaml
generated
|
|
@ -184,6 +184,9 @@ importers:
|
||||||
'@tiptap/extension-placeholder':
|
'@tiptap/extension-placeholder':
|
||||||
specifier: ^3.15.3
|
specifier: ^3.15.3
|
||||||
version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
|
version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
|
||||||
|
'@tiptap/extension-table':
|
||||||
|
specifier: ^3.22.4
|
||||||
|
version: 3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
|
||||||
'@tiptap/extension-task-item':
|
'@tiptap/extension-task-item':
|
||||||
specifier: ^3.15.3
|
specifier: ^3.15.3
|
||||||
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
|
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
|
||||||
|
|
@ -265,6 +268,9 @@ importers:
|
||||||
use-stick-to-bottom:
|
use-stick-to-bottom:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(react@19.2.3)
|
version: 1.1.1(react@19.2.3)
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
|
@ -3163,6 +3169,12 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@tiptap/core': ^3.15.3
|
'@tiptap/core': ^3.15.3
|
||||||
|
|
||||||
|
'@tiptap/extension-table@3.22.4':
|
||||||
|
resolution: {integrity: sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.22.4
|
||||||
|
'@tiptap/pm': 3.22.4
|
||||||
|
|
||||||
'@tiptap/extension-task-item@3.15.3':
|
'@tiptap/extension-task-item@3.15.3':
|
||||||
resolution: {integrity: sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==}
|
resolution: {integrity: sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -11148,6 +11160,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
|
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-table@3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
|
||||||
|
'@tiptap/pm': 3.15.3
|
||||||
|
|
||||||
'@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
|
'@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
|
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue