diff --git a/CLAUDE.md b/CLAUDE.md index 6bbcf22b..b10d5234 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Long-form docs for specific features. Read the relevant file before making chang | Feature | Doc | |---------|-----| -| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` | +| Tracks — frontmatter directives that keep a note's body auto-updated (cron / window / once / event / multi-trigger), section-placement model, sidebar UI, Copilot skill, prompts catalog | `apps/x/TRACKS.md` | | Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` | ## Common Tasks diff --git a/apps/x/TRACKS.md b/apps/x/TRACKS.md index 3caf9e41..3d9662f2 100644 --- a/apps/x/TRACKS.md +++ b/apps/x/TRACKS.md @@ -1,24 +1,29 @@ -# Track Blocks +# Tracks -> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand. +> Frontmatter directives that keep a markdown note's body auto-updated — on a schedule, when a relevant event arrives, 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. +A track is a single entry in a note's YAML frontmatter under the `track:` array. Each entry defines an instruction, optional triggers (cron / window / once / event — any mix), and (after the first run) some runtime state. When a trigger fires, a background agent edits the **note body** to satisfy the instruction. A note with no `track:` key is just a static note. -**Example** (a Chicago-time track refreshed hourly): +**Example** (a note that shows the current Chicago time, 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: + - id: chicago-time + instruction: | + Show the current time in Chicago, IL in 12-hour format. + active: true + triggers: + - type: cron + expression: "0 * * * *" + lastRunAt: "2026-05-07T15:00:01.234Z" + lastRunId: "..." + lastRunSummary: "Updated — 3:00 PM, Central Time." +--- - -2:30 PM, Central Time - +# Chicago time + +3:00 PM, Central Time ~~~ ## Table of Contents @@ -27,279 +32,304 @@ schedule: 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) +5. [Section Placement](#section-placement) +6. [Daily-Note Template & Migrations](#daily-note-template--migrations) +7. [Renderer UI](#renderer-ui) +8. [Prompts Catalog](#prompts-catalog) +9. [File Map](#file-map) --- ## Product Overview -### Trigger types +### Triggers -A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track. +A track has zero or more triggers under a single `triggers:` array. Each trigger is one of four types and can be mixed freely: -| Trigger | When it fires | How to express it | +| Type | When it fires | Shape | |---|---|---| -| **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"` | +| **`cron`** | At exact cron times | `{ type: cron, expression: "0 * * * *" }` | +| **`window`** | Once per day, anywhere inside a time-of-day band | `{ type: window, startTime: "09:00", endTime: "12:00" }` | +| **`once`** | Once at a future time, then never | `{ type: once, runAt: "2026-04-14T09:00:00" }` | +| **`event`** | When a matching event arrives (e.g. new Gmail thread) | `{ type: event, matchCriteria: "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. +A track with no `triggers` (or an empty array) is **manual-only** — fires only when the user clicks Run in the sidebar. + +`cron` and `once` enforce a 2-minute grace window — if the scheduled time passed more than 2 minutes ago (e.g. the app was offline), the run is skipped, not replayed. `window` is forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, it fires the moment the app is open. The day's cycle is anchored at `startTime`. + +A single track can carry multiple triggers. The flagship example is in Today.md's `priorities` track: three `window` entries (morning / midday / post-lunch) plus two `event` entries (gmail / calendar) — five triggers total, giving a baseline rebuild three times per day plus reactive updates on incoming signals. ### Creating a track -Three paths, all produce identical on-disk YAML: +Two paths, both producing 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. +1. **Hand-written** — type the entry directly into a note's frontmatter under `track:`. The scheduler picks it up on its next 15-second tick. +2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond the literal word "track" (see "Prompts Catalog → Copilot trigger paragraph" for the signal taxonomy); it loads the `tracks` skill, edits the note's frontmatter via `workspace-edit`, then **runs the track once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. -### Viewing and managing a track +There is no inline-block creation flow anymore. The Cmd+K palette is search-only and does not invoke Copilot. -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. +### Viewing and managing tracks -Clicking the chip opens the **track modal**, where everything happens: +The editor has a Radio-icon button in the top toolbar (right side) that opens the **Track Sidebar** for the current note. The sidebar: -- **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). +- **List view** — one row per track in the note's frontmatter. Title is the track's `id`; subtitle is the trigger summary plus a `Paused ·` prefix when applicable, plus the instruction's first line as a tertiary line. A Play button on the right runs that track. +- **Detail view** (click a row) — back arrow + tabs (*What* / *Schedule* / *Events* / *Details*), an advanced raw-YAML editor, danger-zone delete, and a footer with "Edit with Copilot" + "Run now". +- **Status hook** — `useTrackStatus` subscribes to `tracks:events` IPC; rows show a spinner whenever a track is running, regardless of hover state. -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`. +Every mutation in the sidebar goes through IPC to the backend — the renderer never writes the file itself. This avoids races with the scheduler/runner writing runtime fields like `lastRunAt`. -### What Copilot can do +### What the runtime agent does -- **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. +When a trigger fires, a background agent ("track-run") receives a short message: +- The track's `id`, the workspace-relative path to the note, and a localized timestamp. +- The instruction. +- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). -### After a run +The agent's system prompt tells it to: +1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). +2. Find or create the H2 section the instruction names (placement model below). +3. Update only that section's content. Never modify YAML frontmatter — that's owned by the user and the runtime. +4. After writing, re-check its section's position; cut-and-paste only its own block if it's misplaced (handles the cold-start firing-order problem). +5. End with a one-line summary stored as `lastRunSummary`. -- The **target region** (between `` markers) is rewritten by the track-run agent using the `update-track-content` tool. -- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML. -- The chip pulses while running, then displays the latest `lastRunAt`. -- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook. +The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP) — there's no special "track-content" tool anymore; tracks just ship general edits. --- ## Architecture at a Glance ``` -Editor chip (display-only) ──click──► TrackModal (React) - │ - ├──► IPC: track:get / update / - │ replaceYaml / delete / run - │ +Editor toolbar Radio button ─click──► TrackSidebar (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 + ├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent + └─ Builtin tool run-track ─┘ │ + ▼ + workspace-readFile / -edit + │ + ▼ + body region rewritten on disk + frontmatter lastRun* patched ``` -**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. +**Single-writer invariant** — the renderer is never a file writer for the `track:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrack`, `replaceTrackYaml`, `deleteTrack`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `track:` key (and `buildFrontmatter` splices it back from the original raw on save), so the FrontmatterProperties UI can't accidentally mangle it. -**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. +**Event contract** — `window.dispatchEvent(CustomEvent('rowboat:open-track-sidebar', { detail: { filePath } }))` is the sole entry point from editor toolbar → sidebar. `rowboat:open-copilot-edit-track` opens the Copilot sidebar with the note attached. --- ## Technical Flows -### 4.1 Scheduling (cron / window / once) +### 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()`. +- **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 tracks via `fetchAll(relPath)`. +- For each track with `active === true` and at least one timed trigger (`cron` / `window` / `once`), `find` the first due trigger via `isTriggerDue(t, lastRunAt)` (`schedule-utils.ts`). +- When due, fire `triggerTrackUpdate(track.id, relPath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates). +- **Grace window** — `cron` and `once` enforce a 2-minute grace; missed schedules are skipped, not replayed. `window` has no grace — anywhere inside the band counts. +- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a fire lands strictly after today's `startTime`, that window is done for the day. The strict comparison handles the boundary case (e.g. an 08:00–12:00 + a 12:00–15:00 window each get to fire even when the morning fire happens exactly at 12:00:00). +- **Startup** — `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`. -### 4.2 Event pipeline +### Event pipeline **Producers** — any data source that should feed tracks emits events: - -- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: })`. -- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`. +- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: })`. +- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`. **Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO. **Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event: - 1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive). -2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`. -3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below). -4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. -5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/.json`, unlink from `pending/`. +2. `listAllTracks()` scans every `.md` under `knowledge/`. Only tracks with at least one `event`-type trigger appear in the routing list; their `eventMatchCriteria` is the joined `matchCriteria` from all event triggers (`'; '`-separated). +3. `findCandidates(event, allTracks)` runs Pass 1 LLM routing (below). +4. For each candidate, `triggerTrackUpdate(id, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. +5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then move to `events/done/.json`. -**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. +**Pass 1 routing** (`routing.ts`): +- **Short-circuit** — if `event.targetTrackId` + `event.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. +- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `id` is only unique per file. -**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. +**Pass 2 decision** happens inside the track-run agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body. -### 4.3 Run flow (`triggerTrackUpdate`) +### Run flow (`triggerTrackUpdate`) Module: `packages/core/src/knowledge/track/runner.ts`. -1. **Concurrency guard** — static `runningTracks: Set` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. -2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`. -3. **Create agent run** — `createRun({ agentId: 'track-run' })`. -4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set. -5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). -6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive. -7. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. -8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`. -9. **Store `lastRunSummary`** via `updateTrackBlock`. -10. **Emit `track_run_complete`** with `summary` or `error`. -11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. +1. **Concurrency guard** — static `runningTracks: Set` keyed by `${id}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. +2. **Fetch track** via `fetchAll(filePath)`, locate by `id`. +3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff. +4. **Create agent run** — `createRun({ agentId: 'track-run' })`. +5. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger; for `once` tracks the "done" marker is already set. +6. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). +7. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly. +8. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. +9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`. +10. **Patch `lastRunSummary`** via `updateTrack(filePath, id, { lastRunSummary })`. +11. **Emit `track_run_complete`** with `summary` or `error`. +12. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`. -### 4.4 IPC surface +### 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 | +| `track:run` | Renderer (sidebar Run button) | Fires `triggerTrackUpdate(..., 'manual')` | +| `track:get` | Sidebar on detail open | Returns fresh per-track YAML from disk via `fetchYaml(filePath, id)` | +| `track:update` | Sidebar toggle / partial edits | `updateTrack` merges a partial into the on-disk entry | +| `track:replaceYaml` | Sidebar advanced raw-YAML save | `replaceTrackYaml` validates + writes the full entry | +| `track:delete` | Sidebar danger-zone confirm | `deleteTrack` removes the entry from the `track:` array | +| `track:setNoteActive` | Background-agents view toggle | Flips `active` on every track in a note | +| `track:listNotes` | Background-agents view load | Lists all notes that contain at least one track, with summary fields | +| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to `useTrackStatus` | 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 +### Concurrency & FIFO guarantees -- **Chip** — `apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save. -- **Modal** — `apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called. -- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state. -- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file. - -### 4.6 Copilot skill integration - -- **Skill content** — `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called. -- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync. -- **Skill registration** — `packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array). -- **Loading trigger** — `packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests. -- **Builtin tools** — `packages/core/src/application/lib/builtin-tools.ts`: - - `update-track-content` — low-level: rewrite the target region between `` markers. Used mainly by the track-run agent. - - `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`). - -### 4.7 Concurrency & FIFO guarantees - -- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC. -- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file. -- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too. +- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`. +- **Backend is single writer for `track:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `track:` byte-for-byte across saves. +- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file. +- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially. - **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`: +All canonical schemas live in `packages/shared/src/track.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' }`. +- `TrackSchema` — a single entry in the frontmatter `track:` array. Fields: `id` (kebab-case, unique within the note), `instruction`, `active` (default true), `triggers?`, `model?`, `provider?`, `icon?`. **Runtime-managed (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`. +- `TriggerSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' | 'event' }`. Window has just `startTime` + `endTime` (no `cron` field — the cycle is anchored at `startTime`). - `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. +The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(TrackSchema))` — so editing `TrackSchema` propagates to the skill on the next build. + +--- + +## Section Placement + +Tracks no longer have formal target regions. Each instruction names a section by H2 heading (e.g. *"in a section titled 'Overview' at the top"*) and the agent finds or creates that section. + +The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/track/run-agent.ts`): + +- Sections are **H2 headings** (`## Section Name`). Match by exact heading text. +- **Existing**: replace its content (everything between that heading and the next H2 — or end of file). Heading itself stays. +- **Missing**: create it. The placement hint determines location: + - "at the top" → just below the H1 title. + - "after X" → immediately after section X. + - no hint → append. +- **Self-heal**: after writing, the agent re-checks its section's position. If misplaced (the cold-start case where empty notes get sections in firing order rather than reading order), the agent moves only its **own** H2 block — never reorders other tracks' sections. +- **Boundaries**: never modify another track's section content; never duplicate; never touch frontmatter; if the user renamed the heading, recreate per the placement hint. + +This keeps tracks loosely coupled: each one stakes out a section by name, and the rest of the body is entirely the user's. + +--- + +## Daily-Note Template & Migrations + +`Today.md` is the canonical demo of what tracks can do. It ships with six tracks (overview/photo combined into one, calendar, emails, what-you-missed, priorities) showing pure-cron, pure-event, multi-window, and multi-trigger configurations. + +**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: + +- File missing → fresh write at canonical version. +- File at-or-above canonical → no-op. +- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template with the body byte-preserved via `splitFrontmatter` from `application/lib/parse-frontmatter.ts`. + +Any change to the canonical TRACKS list, instructions, default body, or trigger config should bump the constant. Existing users will get the new template on next launch with their body sections preserved; their `lastRunAt` and any custom additions to the tracks list are dropped (the .bkp file is the recovery path). + +--- + +## Renderer UI + +The chip-in-editor model is gone. Replacements: + +- **Toolbar button** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon ghost button at the top-right of the editor toolbar. `markdown-editor.tsx` passes `onOpenTracks` (only when a `notePath` is available) which dispatches `rowboat:open-track-sidebar` with `{ filePath }`. +- **Sidebar** — `apps/renderer/src/components/track-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-track-sidebar`; on open, calls `workspace:readFile` and parses tracks from the frontmatter on the renderer side (uses the same `TrackSchema` from `@x/shared`). All mutations go through IPC. + - Constant top header: Radio icon, "Tracks" title, note name subtitle, X close. Uses the `bg-sidebar` design tokens to match the app's left sidebar. + - List view: one row per track. Title is `id`; subtitle is the trigger summary (with `Paused ·` prefix); third line is the instruction's first line, truncated. Run button always visible while running, otherwise fades in on hover. + - Detail view: back arrow + track id; status row (trigger summary + Active/Paused toggle); tabs (`What` / `Schedule` / `Events` / `Details`); advanced raw-YAML editor; danger-zone delete; footer (Edit with Copilot + Run now). +- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` IPC and maintains a `Map<"${id}:${filePath}", RunState>` keyed by composite key. +- **Edit-with-Copilot flow** — sidebar dispatches `rowboat:open-copilot-edit-track` (App.tsx listener handles it via `submitFromPalette`). +- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` adds `STRUCTURED_KEYS = new Set(['track'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `track:` block back from `preserveRaw` on save. This means the property panel can edit `tags` / `status` / etc. without ever clobbering the tracks frontmatter. --- ## 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`). +Every LLM-facing prompt in the feature, with file 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. ### 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. +- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; the run-agent does Pass 2. +- **File**: `packages/core/src/knowledge/track/routing.ts` (`ROUTING_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 })`. +- **Invoked by**: `findCandidates()` 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). +- **Purpose**: formats the event and the current batch of tracks into the user message for Pass 1. +- **File**: `packages/core/src/knowledge/track/routing.ts` (`buildRoutingPrompt`). +- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `matchCriteria` — joined from all event triggers, `'; '`-separated). +- **Output**: plain text, two sections — `## Event` and `## Tracks`. ### 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). +- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the section-placement contract (find/create/self-heal), points at the knowledge graph, and prescribes general `workspace-readFile` / `workspace-edit` as the write path. +- **File**: `packages/core/src/knowledge/track/run-agent.ts` (`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`. +- **Invoked by**: `buildTrackRunAgent()`, 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`. +- **Purpose**: the user message seeded into each track-run. +- **File**: `packages/core/src/knowledge/track/runner.ts` (`buildMessage`). +- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `track.id`, `track.instruction`, all event triggers' `matchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. +- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run). -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. +Three branches by `trigger`: +- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-track` 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. +- **`event`** — adds a Pass 2 decision block listing all event triggers' `matchCriteria` (numbered if multiple) and the event payload, with the directive to skip the edit if the event isn't truly relevant. ### 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. +- **Purpose**: teaches Copilot the frontmatter `track:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, user-facing language (call them "tracks"; surface the **Track sidebar** by name), the auto-run-once-on-create/edit default, schema, triggers, multi-trigger combos, YAML-safety rules, insertion workflow, and the `run-track` tool with `context` backfills. +- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts`. Exported `skill` constant. +- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(TrackSchema))` is interpolated into the "Canonical Schema" section. Edits to `TrackSchema` propagate 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. +- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`. ### 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. +- **Purpose**: tells Copilot *when* to load the `tracks` skill, and frames how aggressively to act once loaded. +- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Tracks (Auto-Updating Notes)" paragraph). +- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…"). +- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up. +- **Anti-signals (do NOT track)**: definitional questions, one-off lookups, manual document editing. -### 7. `run-track-block` tool — `context` parameter description +### 7. `run-track` 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. +- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. +- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-track` tool definition). +- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), `id`, optional `context`. - **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"`. +- **Key use case**: backfill a newly-created event-driven track so its section isn't empty on day 1. ### 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**: `packages/core/src/knowledge/sync_calendar.ts` (`summarizeCalendarSync`, wrapped by `publishCalendarSyncEvent()`). +- **Output**: markdown with a counts header, `## Changed events` (per-event block: title, ID, time, organizer, location, attendees, truncated description), `## Deleted event IDs`. Capped at ~50 events; descriptions truncated to 500 chars. +- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. --- @@ -307,37 +337,30 @@ Three branches: | Purpose | File | |---|---| -| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` | +| Zod schemas (track, triggers, events, Pass1) | `packages/shared/src/track.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` | +| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` | +| File operations (fetchAll / fetch / updateTrack / replaceTrackYaml / deleteTrack / readNoteBody / list / setActive) | `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` | +| Trigger 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` | +| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | | Copilot skill | `packages/core/src/application/assistant/skills/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` | +| `run-track` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` | +| Editor toolbar (Radio button → sidebar) | `apps/renderer/src/components/editor-toolbar.tsx` | +| Track sidebar (list + detail view) | `apps/renderer/src/components/track-sidebar.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` | +| Renderer frontmatter helper (preserves `track:`) | `apps/renderer/src/lib/frontmatter.ts` | +| App-level listeners (sidebar open + Copilot edit) | `apps/renderer/src/App.tsx` | +| CSS (sidebar styles, legacy filename) | `apps/renderer/src/styles/track-modal.css`, `apps/renderer/src/styles/editor.css` | | Main process startup (schedulers & processors) | `apps/main/src/main.ts` | - ---- - -## Known Follow-ups - -- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields. - - **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save. - - **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor). - -- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow. diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 178cb7e1..ad639a86 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,6 +11,9 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + protocols: [ + { name: 'Rowboat', schemes: ['rowboat'] }, + ], extendInfo: { NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', }, diff --git a/apps/x/apps/main/src/browser/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts index b83ea7cb..7c97ea7a 100644 --- a/apps/x/apps/main/src/browser/control-service.ts +++ b/apps/x/apps/main/src/browser/control-service.ts @@ -1,8 +1,24 @@ 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 type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js'; +import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js'; import { browserViewManager } from './view.js'; import { normalizeNavigationTarget } from './navigation.js'; +async function getSuggestedSkills(url: string | undefined): Promise { + if (!url) return undefined; + try { + const status = await ensureLoaded(); + if (status.status === 'ready' || status.status === 'stale') { + const matched = matchSkillsForUrl(status.index, url); + if (matched.length === 0) return undefined; + return matched.map((e) => ({ id: e.id, title: e.title, path: e.path })); + } + } catch (err) { + console.warn('[browser-control] suggestedSkills lookup failed:', err); + } + return undefined; +} + function buildSuccessResult( action: BrowserControlAction, message: string, @@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult( 'new-tab', target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', page, ); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'switch-tab': { @@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('navigate', `Navigated to ${target}.`, page); + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'back': { @@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { 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); + const suggestedSkills = await getSuggestedSkills(result.page.url); + const success = buildSuccessResult('read-page', 'Read the current page.', result.page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'click': { diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index d319c5fb..90b7d849 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -109,19 +109,62 @@ export class BrowserViewManager extends EventEmitter { private visible = false; private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; private snapshotCache = new Map(); + private cleanupWindowListeners: (() => void) | null = null; attach(window: BrowserWindow): void { + this.cleanupWindowListeners?.(); + this.cleanupWindowListeners = null; this.window = window; - window.on('closed', () => { + const hostWebContents = window.webContents; + + const resetForHostWindowNavigation = () => { + // Renderer refreshes do not run React unmount cleanup reliably, so the + // native browser view must be detached from the main process side. + this.visible = false; + this.bounds = { x: 0, y: 0, width: 0, height: 0 }; + this.syncAttachedView(); + }; + + const handleDidStartLoading = () => { + resetForHostWindowNavigation(); + }; + + const handleRenderProcessGone = () => { + resetForHostWindowNavigation(); + }; + + const handleClosed = () => { + if (this.window !== window) return; + + const tabs = [...this.tabs.values()]; + this.cleanupWindowListeners = null; this.window = null; this.browserSession = null; + this.bounds = { x: 0, y: 0, width: 0, height: 0 }; + for (const tab of tabs) { + this.destroyTab(tab); + } this.tabs.clear(); this.tabOrder = []; this.activeTabId = null; this.attachedTabId = null; this.visible = false; this.snapshotCache.clear(); - }); + }; + + hostWebContents.on('did-start-loading', handleDidStartLoading); + hostWebContents.on('render-process-gone', handleRenderProcessGone); + window.on('closed', handleClosed); + + this.cleanupWindowListeners = () => { + if (!hostWebContents.isDestroyed()) { + hostWebContents.removeListener('did-start-loading', handleDidStartLoading); + hostWebContents.removeListener('render-process-gone', handleRenderProcessGone); + } + if (!window.isDestroyed()) { + window.removeListener('closed', handleClosed); + } + }; } private getSession(): Session { diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 274cfb2a..8fc4b754 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -293,20 +293,6 @@ export function listConnected(): { toolkits: string[] } { return { toolkits: composioAccountsRepo.getConnectedToolkits() }; } -/** - * Check if Composio should be used for Google services (Gmail, etc.) - */ -export async function useComposioForGoogle(): Promise<{ enabled: boolean }> { - return { enabled: await composioClient.useComposioForGoogle() }; -} - -/** - * Check if Composio should be used for Google Calendar - */ -export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> { - return { enabled: await composioClient.useComposioForGoogleCalendar() }; -} - /** * List available Composio toolkits — filtered to curated list only. * Return type matches the ZToolkit schema from core/composio/types.ts. diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts new file mode 100644 index 00000000..aaaaa3bc --- /dev/null +++ b/apps/x/apps/main/src/deeplink.ts @@ -0,0 +1,165 @@ +import { BrowserWindow } from "electron"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +export const DEEP_LINK_SCHEME = "rowboat"; +const URL_PREFIX = `${DEEP_LINK_SCHEME}://`; +const ACTION_HOST = "action"; + +let pendingUrl: string | null = null; +let mainWindowRef: BrowserWindow | null = null; + +export function setMainWindowForDeepLinks(win: BrowserWindow | null): void { + mainWindowRef = win; +} + +export function consumePendingDeepLink(): string | null { + const url = pendingUrl; + pendingUrl = null; + return url; +} + +export function extractDeepLinkFromArgv(argv: readonly string[]): string | null { + for (const arg of argv) { + if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg; + } + return null; +} + +/** + * Dispatch any rowboat:// URL — chooses among action / oauth-completion / + * navigation automatically. Use this from notification click handlers and + * other URL entry points. + * + * OAuth completion (rowboat://oauth/google/done?session=) is handled + * in main, not the renderer, because claiming tokens writes oauth.json and + * triggers sync — both main-process concerns. + */ +export function dispatchUrl(url: string): void { + if (parseAction(url)) { + void dispatchAction(url); + } else if (parseOAuthCompletion(url)) { + void dispatchOAuthCompletion(url); + } else { + dispatchDeepLink(url); + } +} + +export function dispatchDeepLink(url: string): void { + if (!url.startsWith(URL_PREFIX)) return; + + pendingUrl = url; + + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + if (win.webContents.isLoading()) return; + + win.webContents.send("app:openUrl", { url }); + pendingUrl = null; +} + +interface MeetingNotesAction { + type: "take-meeting-notes" | "join-and-take-meeting-notes"; + eventId: string; +} + +type ParsedAction = MeetingNotesAction; + +function parseAction(url: string): ParsedAction | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, ""); + if (host !== ACTION_HOST) return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const type = params.get("type"); + if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { + const eventId = params.get("eventId"); + return eventId ? { type, eventId } : null; + } + return null; +} + +async function dispatchAction(url: string): Promise { + const parsed = parseAction(url); + if (!parsed) return; + + const openMeeting = parsed.type === "join-and-take-meeting-notes"; + await handleTakeMeetingNotes(parsed.eventId, openMeeting); +} + +async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise { + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); + let event: unknown; + try { + const raw = await fs.readFile(filePath, "utf-8"); + event = JSON.parse(raw); + } catch (err) { + console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); + return; + } + + const payload = { event, openMeeting }; + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("app:takeMeetingNotes", payload); + }); + return; + } + + win.webContents.send("app:takeMeetingNotes", payload); +} + +// --- OAuth completion (rowboat-mode Google connect) --- + +interface OAuthCompletion { + provider: "google"; + state: string; +} + +/** + * Match rowboat://oauth/google/done?session=. Returns null for + * anything else — including paths with the right shape but wrong provider + * or a missing `session` query param. + */ +function parseOAuthCompletion(url: string): OAuthCompletion | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest; + const parts = path.split("/").filter(Boolean); + if (parts.length !== 3 || parts[0] !== "oauth" || parts[2] !== "done") return null; + if (parts[1] !== "google") return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const state = params.get("session"); + return state ? { provider: "google", state } : null; +} + +async function dispatchOAuthCompletion(url: string): Promise { + const parsed = parseOAuthCompletion(url); + if (!parsed) return; + + // Bring the app to the front so the renderer can react to the + // oauthEvent IPC that completeRowboatGoogleConnect emits. + const win = mainWindowRef; + if (win && !win.isDestroyed()) focusWindow(win); + + // Lazy-import to keep deeplink.ts free of OAuth deps and avoid a + // potential circular dep with oauth-handler.ts. + const { completeRowboatGoogleConnect } = await import("./oauth-handler.js"); + await completeRowboatGoogleConnect(parsed.state); +} + +function focusWindow(win: BrowserWindow): void { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5e62e8ee..3d888ee9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -34,6 +34,8 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { consumePendingDeepLink } from './deeplink.js'; +import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; @@ -50,9 +52,11 @@ import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; import { fetchYaml, - updateTrackBlock, - replaceTrackBlockYaml, - deleteTrackBlock, + listNotesWithTracks, + setNoteTracksActive, + updateTrack, + replaceTrackYaml, + deleteTrack, } from '@x/core/dist/knowledge/track/fileops.js'; import { browserIpcHandlers } from './browser/ipc.js'; @@ -133,6 +137,14 @@ function resolveShellPath(filePath: string): string { return workspace.resolveWorkspacePath(filePath); } +function toKnowledgeTrackPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized.startsWith('knowledge/')) { + throw new Error('Track note path must be within knowledge/') + } + return normalized.slice('knowledge/'.length) +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -417,6 +429,9 @@ export function setupIpcHandlers() { // args is null for this channel (no request payload) return getVersions(); }, + 'app:consumePendingDeepLink': async () => { + return { url: consumePendingDeepLink() }; + }, 'analytics:bootstrap': async () => { return { installationId: getInstallationId(), @@ -608,11 +623,8 @@ export function setupIpcHandlers() { 'composio:list-toolkits': async () => { return composioHandler.listToolkits(); }, - 'composio:use-composio-for-google': async () => { - return composioHandler.useComposioForGoogle(); - }, - 'composio:use-composio-for-google-calendar': async () => { - return composioHandler.useComposioForGoogleCalendar(); + 'migration:check-composio-google': async () => { + return qualifyAndDisconnectComposioGoogle(); }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { @@ -673,6 +685,19 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, + 'dialog:openDirectory': async (event, args) => { + const win = BrowserWindow.fromWebContents(event.sender); + const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir(); + const result = await dialog.showOpenDialog(win!, { + title: args.title ?? 'Choose work directory', + defaultPath, + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) { + return { path: null }; + } + return { path: result.filePaths[0] ?? null }; + }, // Knowledge version history handlers 'knowledge:history': async (_event, args) => { const commits = await versionHistory.getFileHistory(args.path); @@ -790,12 +815,12 @@ export function setupIpcHandlers() { }, // Track handlers 'track:run': async (_event, args) => { - const result = await triggerTrackUpdate(args.trackId, args.filePath); + const result = await triggerTrackUpdate(args.id, 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); + const yaml = await fetchYaml(args.filePath, args.id); if (yaml === null) return { success: false, error: 'Track not found' }; return { success: true, yaml }; } catch (err) { @@ -804,8 +829,8 @@ export function setupIpcHandlers() { }, 'track:update': async (_event, args) => { try { - await updateTrackBlock(args.filePath, args.trackId, args.updates as Record); - const yaml = await fetchYaml(args.filePath, args.trackId); + await updateTrack(args.filePath, args.id, args.updates as Record); + const yaml = await fetchYaml(args.filePath, args.id); if (yaml === null) return { success: false, error: 'Track vanished after update' }; return { success: true, yaml }; } catch (err) { @@ -814,8 +839,8 @@ export function setupIpcHandlers() { }, 'track:replaceYaml': async (_event, args) => { try { - await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml); - const yaml = await fetchYaml(args.filePath, args.trackId); + await replaceTrackYaml(args.filePath, args.id, args.yaml); + const yaml = await fetchYaml(args.filePath, args.id); if (yaml === null) return { success: false, error: 'Track vanished after replace' }; return { success: true, yaml }; } catch (err) { @@ -824,12 +849,25 @@ export function setupIpcHandlers() { }, 'track:delete': async (_event, args) => { try { - await deleteTrackBlock(args.filePath, args.trackId); + await deleteTrack(args.filePath, args.id); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, + 'track:setNoteActive': async (_event, args) => { + try { + const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active); + if (!note) return { success: false, error: 'No tracks found in note' }; + return { success: true, note }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:listNotes': async () => { + const notes = await listNotesWithTracks(); + return { notes }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 99c77589..a6d7b4e0 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -23,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; +import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.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"; @@ -34,10 +35,17 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService } from "@x/core/dist/di/container.js"; +import { registerBrowserControlService, registerNotificationService } 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"; +import { ElectronNotificationService } from "./notification/electron-notification-service.js"; +import { + DEEP_LINK_SCHEME, + dispatchUrl, + extractDeepLinkFromArgv, + setMainWindowForDeepLinks, +} from "./deeplink.js"; const execAsync = promisify(exec); @@ -47,6 +55,44 @@ const __dirname = dirname(__filename); // run this as early in the main process as possible if (started) app.quit(); +// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link) +// back into the existing process via the 'second-instance' event. +if (!app.requestSingleInstanceLock()) { + console.error('[Main] Another Rowboat instance is already running; exiting this process.'); + app.quit(); + process.exit(0); +} + +// Register as the OS handler for rowboat:// URLs. +// In dev, point at the right argv so the OS can re-invoke us correctly. +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [ + path.resolve(process.argv[1]), + ]); + } +} else { + app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME); +} + +// First-launch URL on Windows/Linux comes through argv. +{ + const initialUrl = extractDeepLinkFromArgv(process.argv); + if (initialUrl) dispatchUrl(initialUrl); +} + +// macOS sends URLs via 'open-url' (both first launch and while running). +app.on("open-url", (event, url) => { + event.preventDefault(); + dispatchUrl(url); +}); + +// Subsequent launches on Windows/Linux land here via the single-instance lock. +app.on("second-instance", (_event, argv) => { + const url = extractDeepLinkFromArgv(argv); + if (url) dispatchUrl(url); +}); + // Fix PATH for packaged Electron apps on macOS/Linux. // Packaged apps inherit a minimal environment that doesn't include paths from // the user's shell profile (such as those provided by nvm, Homebrew, etc.). @@ -67,7 +113,9 @@ function initializeExecutionEnvironment(): void { ).trim(); const env = JSON.parse(stdout) as Record; - process.env = { ...env, ...process.env }; + // Let the user's shell environment win for overlapping keys like PATH. + // Finder/launched GUI apps on macOS often start with a stripped PATH. + process.env = { ...process.env, ...env }; } catch (error) { console.error('Failed to load shell environment', error); } @@ -165,6 +213,9 @@ function createWindow() { configureSessionPermissions(session.defaultSession); configureSessionPermissions(session.fromPartition(BROWSER_PARTITION)); + setMainWindowForDeepLinks(win); + win.on("closed", () => setMainWindowForDeepLinks(null)); + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { win.maximize(); @@ -240,6 +291,7 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); + registerNotificationService(new ElectronNotificationService()); setupIpcHandlers(); setupBrowserEventForwarding(); @@ -298,6 +350,9 @@ app.whenReady().then(async () => { // start agent notes learning service initAgentNotes(); + // start calendar meeting notification service (fires 1-minute warnings) + initCalendarNotifications(); + // start chrome extension sync server initChromeSync(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts new file mode 100644 index 00000000..dd37e37d --- /dev/null +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -0,0 +1,84 @@ +import { BrowserWindow, Notification, shell } from "electron"; +import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { dispatchUrl } from "../deeplink.js"; + +const HTTP_URL = /^https?:\/\//i; +const ROWBOAT_URL = /^rowboat:\/\//i; + +export class ElectronNotificationService implements INotificationService { + // Holds strong references to active Notification instances so the GC can't + // collect them while they're still visible — without this, the click handler + // gets dropped and macOS clicks just focus the app silently. + private active = new Set(); + + isSupported(): boolean { + return Notification.isSupported(); + } + + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + // Build the actions array AND a parallel index → link map. + // macOS shows actions[0] inline (Banner) or all of them (Alert); + // additional ones live behind the chevron menu. + const actionDefs: Electron.NotificationConstructorOptions["actions"] = []; + const actionLinks: string[] = []; + + const primaryLabel = actionLabel?.trim(); + if (link && primaryLabel) { + actionDefs!.push({ type: "button", text: primaryLabel }); + actionLinks.push(link); + } + if (secondaryActions) { + for (const sa of secondaryActions) { + actionDefs!.push({ type: "button", text: sa.label }); + actionLinks.push(sa.link); + } + } + + const notification = new Notification({ + title, + body: message, + actions: actionDefs, + }); + + this.active.add(notification); + const release = () => { this.active.delete(notification); }; + + const openLink = (target: string | undefined) => { + if (target && ROWBOAT_URL.test(target)) { + dispatchUrl(target); + } else if (target && HTTP_URL.test(target)) { + shell.openExternal(target).catch((err) => { + console.error("[notification] failed to open link:", err); + }); + } else { + this.focusMainWindow(); + } + release(); + }; + + // Body click: always opens the primary `link` (or focuses the app if none). + notification.on("click", () => openLink(link)); + + // Action button click: dispatch by index into the actions array. + notification.on("action", (_event, index) => { + if (index >= 0 && index < actionLinks.length) { + openLink(actionLinks[index]); + } else { + openLink(undefined); + } + }); + + notification.on("close", release); + notification.on("failed", release); + + notification.show(); + } + + private focusMainWindow(): void { + const [win] = BrowserWindow.getAllWindows(); + if (!win) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + } +} diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index d3caba38..f61b59cc 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -13,6 +13,9 @@ import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync import { emitOAuthEvent } from './ipc.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js'; +import { isSignedIn } from '@x/core/dist/account/account.js'; +import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; +import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -201,6 +204,23 @@ export async function connectProvider(provider: string, credentials?: { clientId if (provider === 'google') { if (!credentials?.clientId || !credentials?.clientSecret) { + // No credentials → rowboat mode if the user is signed in to Rowboat + // (we use the company-owned Google client via the api + webapp). + // Otherwise it's BYOK with missing creds → error. + if (await isSignedIn()) { + try { + const webappUrl = await getWebappUrl(); + await shell.openExternal(`${webappUrl}/oauth/google/start`); + console.log('[OAuth] Started rowboat-mode Google connect (browser opened to webapp)'); + return { success: true }; + } catch (error) { + console.error('[OAuth] Failed to start rowboat-mode Google connect:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to open browser', + }; + } + } return { success: false, error: 'Google client ID and client secret are required to connect.' }; } } @@ -257,11 +277,15 @@ export async function connectProvider(provider: string, credentials?: { clientId state ); - // Save tokens and credentials + // Save tokens and credentials. For Google, BYOK is the only path + // that reaches this token exchange (rowboat path returns above + // before any local server runs); stamp mode: 'byok' so a future + // refresh / reconnect can't get confused with a rowboat entry. console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.upsert(provider, { tokens, ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + ...(provider === 'google' ? { mode: 'byok' as const } : {}), error: null, }); @@ -358,12 +382,65 @@ export async function connectProvider(provider: string, credentials?: { clientId } } +/** + * Complete a rowboat-mode Google connect: claim the tokens parked under + * `state` by the webapp callback, persist them locally, and trigger sync. + * + * Called by the deep-link dispatcher (deeplink.ts) when the OS hands us a + * rowboat://oauth/google/done?session= URL. + */ +export async function completeRowboatGoogleConnect(state: string): Promise { + try { + console.log('[OAuth] Claiming rowboat-mode Google tokens...'); + const tokens = await claimTokensViaBackend(state); + const oauthRepo = getOAuthRepo(); + await oauthRepo.upsert('google', { + tokens, + mode: 'rowboat', + // Explicitly null these — no client_id/secret on the desktop in this mode. + clientId: null, + clientSecret: null, + error: null, + }); + triggerGmailSync(); + triggerCalendarSync(); + emitOAuthEvent({ provider: 'google', success: true }); + console.log('[OAuth] Rowboat-mode Google connect complete'); + } catch (error) { + console.error('[OAuth] Failed to complete rowboat-mode Google connect:', error); + emitOAuthEvent({ + provider: 'google', + success: false, + error: error instanceof Error ? error.message : 'Failed to claim Google tokens', + }); + } +} + /** * Disconnect a provider (clear tokens) */ export async function disconnectProvider(provider: string): Promise<{ success: boolean }> { try { const oauthRepo = getOAuthRepo(); + + // For rowboat-mode Google, best-effort revoke at Google before clearing + // local state. Google's revoke endpoint accepts an unauthenticated POST + // with the access_token; failure is logged but doesn't block disconnect. + if (provider === 'google') { + const connection = await oauthRepo.read(provider); + if (connection.mode === 'rowboat' && connection.tokens?.access_token) { + try { + const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; + const res = await fetch(revokeUrl, { method: 'POST' }); + if (!res.ok) { + console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); + } + } catch (error) { + console.warn('[OAuth] Google revoke failed; continuing with local disconnect:', error); + } + } + } + await oauthRepo.delete(provider); if (provider === 'rowboat') { analyticsCapture('user_signed_out'); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index d9216de1..359f1709 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -25,15 +25,16 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tailwindcss/vite": "^4.1.18", - "@tiptap/extension-image": "^3.16.0", - "@tiptap/extension-link": "^3.15.3", - "@tiptap/extension-placeholder": "^3.15.3", - "@tiptap/extension-table": "^3.22.4", - "@tiptap/extension-task-item": "^3.15.3", - "@tiptap/extension-task-list": "^3.15.3", - "@tiptap/pm": "^3.15.3", - "@tiptap/react": "^3.15.3", - "@tiptap/starter-kit": "^3.15.3", + "@tiptap/core": "3.22.4", + "@tiptap/extension-image": "3.22.4", + "@tiptap/extension-link": "3.22.4", + "@tiptap/extension-placeholder": "3.22.4", + "@tiptap/extension-table": "3.22.4", + "@tiptap/extension-task-item": "3.22.4", + "@tiptap/extension-task-list": "3.22.4", + "@tiptap/pm": "3.22.4", + "@tiptap/react": "3.22.4", + "@tiptap/starter-kit": "3.22.4", "@x/preload": "workspace:*", "@x/shared": "workspace:*", "ai": "^5.0.117", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 67f3f06a..ec54e95b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; @@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; +import { BackgroundAgentsView } from '@/components/background-agents-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -35,11 +36,12 @@ import { import { Shimmer } from '@/components/ai-elements/shimmer'; import { useSmoothedText } from './hooks/useSmoothedText'; -import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; +import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; @@ -54,9 +56,11 @@ import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' +import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' -import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' -import { TrackModal } from '@/components/track-modal' +import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' +import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' +import { TrackSidebar } from '@/components/track-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' @@ -76,10 +80,12 @@ import { getAppActionCardData, getComposioConnectCardData, getToolDisplayName, + groupConversationItems, inferRunTitleFromMessage, isChatMessage, isErrorMessage, isToolCall, + isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -116,6 +122,31 @@ function SmoothStreamingMessage({ text, components }: { text: string; components return {smoothText} } +function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { + const ref = useRef(null) + const stickToBottom = useRef(true) + + useLayoutEffect(() => { + const el = ref.current + if (el && stickToBottom.current) { + el.scrollTop = el.scrollHeight + } + }, [children]) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickToBottom.current = atBottom + }, []) + + return ( +
+      {children}
+    
+ ) +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -138,6 +169,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' +const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -267,6 +299,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH +const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -318,11 +351,29 @@ const buildSuggestedTopicExplorePrompt = ({ '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.', + 'Add a track to the note (a `track:` entry in its frontmatter) 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 buildBackgroundAgentSetupPrompt = () => [ + 'Help me set up a background agent.', + 'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.', + 'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.', + 'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.', + 'Start with a short, plain-English explanation of what a background agent is.', + 'Do not make the explanation too terse.', + 'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.', + 'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.', + 'In the first reply, tell me that you will create this in my Tasks folder by default.', + 'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.', + 'Then ask only what I want it to monitor or update and how often I want it to run.', + 'Keep it concise and friendly, but not abrupt.', + 'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.', + 'Do not create or modify anything yet.', + 'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.', +].join('\n') + const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -504,6 +555,7 @@ type ViewState = | { type: 'graph' } | { type: 'task'; name: string } | { type: 'suggested-topics' } + | { type: 'background-agents' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -513,6 +565,48 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } +/** + * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is + * malformed or names an unknown target. + * + * Shape: rowboat://open?type=&... + * file: ?type=file&path=knowledge/foo.md + * chat: ?type=chat&runId=abc123 (runId optional) + * graph: ?type=graph + * task: ?type=task&name=daily-brief + * suggested-topics: ?type=suggested-topics + * background-agents: ?type=background-agents + */ +function parseDeepLink(input: string): ViewState | null { + const SCHEME = 'rowboat://' + if (!input.startsWith(SCHEME)) return null + const rest = input.slice(SCHEME.length) + const queryIdx = rest.indexOf('?') + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '') + if (host !== 'open') return null + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '') + switch (params.get('type')) { + case 'file': { + const path = params.get('path') + return path ? { type: 'file', path } : null + } + case 'chat': + return { type: 'chat', runId: params.get('runId') || null } + case 'graph': + return { type: 'graph' } + case 'task': { + const name = params.get('name') + return name ? { type: 'task', name } : null + } + case 'suggested-topics': + return { type: 'suggested-topics' } + case 'background-agents': + return { type: 'background-agents' } + default: + return null + } +} + /** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ leftInsetPx, @@ -613,7 +707,13 @@ function App() { const [isGraphOpen, setIsGraphOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) - const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null) + const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false) + const [expandedFrom, setExpandedFrom] = useState<{ + path: string | null + graph: boolean + suggestedTopics: boolean + backgroundAgents: boolean + } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], @@ -738,6 +838,30 @@ function App() { return cleanup }, [refreshVoiceAvailability]) + // One-time Composio→native Google migration check. Runs on mount and again + // after the user signs in to Rowboat (so we catch users who weren't signed + // in at startup). The IPC is idempotent — once `dismissed_at` is set on the + // main side, every subsequent call returns `{shouldShow: false}`. + useEffect(() => { + const run = async () => { + try { + const result = await window.ipc.invoke('migration:check-composio-google', null) + if (result.shouldShow) { + setShowComposioGoogleMigration(true) + } + } catch (error) { + console.error('[migration] check-composio-google failed:', error) + } + } + void run() + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' && event.success) { + void run() + } + }) + return cleanup + }, []) + const handleStartRecording = useCallback(() => { setIsRecording(true) isRecordingRef.current = true @@ -750,7 +874,6 @@ function App() { // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload // queued across the new-chat-tab state flush before submit fires. const editorRefsByTabId = useRef>(new Map()) - const [paletteContext, setPaletteContext] = useState(null) const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null) const handleSubmitRecording = useCallback(() => { @@ -910,6 +1033,7 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' + if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -991,6 +1115,9 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // One-time Composio→native Google migration modal + const [showComposioGoogleMigration, setShowComposioGoogleMigration] = useState(false) + // Search state const [isSearchOpen, setIsSearchOpen] = useState(false) @@ -1983,6 +2110,10 @@ function App() { return next }) + if (event.toolCallId && event.toolName !== 'executeCommand') { + setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) + } + // Handle app-navigation tool results — trigger UI side effects if (event.toolName === 'app-navigation') { const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined @@ -1994,6 +2125,23 @@ function App() { break } + case 'tool-output-stream': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if ( + isToolCall(item) + && item.id === event.toolCallId + ) { + if (!item.streamingOutput) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, streamingOutput: (item.streamingOutput ?? '') + event.output } + } + return item + })) + break + } + case 'tool-permission-request': { if (!isActiveRun) return const key = event.toolCall.toolCallId @@ -2358,6 +2506,10 @@ function App() { } }, [runId]) + const dismissBrowserOverlay = useCallback(() => { + setIsBrowserOpen(false) + }, []) + const handleNewChat = useCallback(() => { // Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in) loadRunRequestIdRef.current += 1 @@ -2581,10 +2733,13 @@ function App() { // File tab operations const openFileInNewTab = useCallback((path: string) => { + dismissBrowserOverlay() const existingTab = fileTabs.find(t => t.path === path) if (existingTab) { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(path) return } @@ -2592,12 +2747,15 @@ function App() { setFileTabs(prev => [...prev, { id, path }]) setActiveFileTabId(id) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(path) - }, [fileTabs]) + }, [fileTabs, dismissBrowserOverlay]) const switchFileTab = useCallback((tabId: string) => { const tab = fileTabs.find(t => t.id === tabId) if (!tab) return + dismissBrowserOverlay() setActiveFileTabId(tabId) setSelectedBackgroundTask(null) setExpandedFrom(null) @@ -2609,18 +2767,28 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + return + } + if (isBackgroundAgentsTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) return } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(tab.path) - }, [fileTabs, isRightPaneMaximized]) + }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) @@ -2647,6 +2815,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2660,13 +2829,21 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + } else if (isBackgroundAgentsTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2692,12 +2869,18 @@ function App() { // Create a new tab const id = newChatTabId() setChatTabs(prev => [...prev, { id, runId: null }]) - setActiveChatTabId(id) + setActiveChatTabId(id) } + dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + setExpandedFrom({ + path: selectedPath, + graph: isGraphOpen, + suggestedTopics: isSuggestedTopicsOpen, + backgroundAgents: isBackgroundAgentsOpen, + }) } else { setExpandedFrom(null) } @@ -2705,7 +2888,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + setIsBackgroundAgentsOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2748,8 +2932,7 @@ function App() { setPendingPaletteSubmit(null) }, [pendingPaletteSubmit]) - // Listener for track-block "Edit with Copilot" events - // (dispatched by apps/renderer/src/extensions/track-block.tsx) + // Listener for "Edit with Copilot" events from the track sidebar. useEffect(() => { const handler = (e: Event) => { const ev = e as CustomEvent<{ @@ -2820,26 +3003,40 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + setExpandedFrom({ + path: selectedPath, + graph: isGraphOpen, + suggestedTopics: isSuggestedTopicsOpen, + backgroundAgents: isBackgroundAgentsOpen, + }) } + dismissBrowserOverlay() setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + setIsBackgroundAgentsOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + } else if (expandedFrom.backgroundAgents) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -2849,11 +3046,12 @@ function App() { const currentViewState = React.useMemo(() => { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (isBackgroundAgentsOpen) return { type: 'background-agents' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -2910,6 +3108,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureBackgroundAgentsFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -2919,6 +3128,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -2933,6 +3143,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -2945,6 +3156,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -2957,17 +3169,29 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) ensureSuggestedTopicsFileTab() return - case 'chat': + case 'background-agents': setSelectedPath(null) setIsGraphOpen(false) - // Don't touch isBrowserOpen here — chat navigation should land in - // the right sidebar when the browser overlay is active. + setIsBrowserOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) + ensureBackgroundAgentsFileTab() + return + case 'chat': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -2975,11 +3199,16 @@ function App() { } return } - }, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState - if (viewStatesEqual(current, nextView)) return + if (viewStatesEqual(current, nextView)) { + if (isBrowserOpen) { + dismissBrowserOverlay() + } + return + } cancelRecordingIfActive() const nextHistory = { @@ -2988,7 +3217,7 @@ function App() { } setHistory(nextHistory) await applyViewState(nextView) - }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory]) + }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -3048,6 +3277,58 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + // Deep-link handler kept in a ref so the useEffect below can register the + // IPC listener (and run the one-time pending-link drain) just once on mount, + // rather than re-running on every navigation when navigateToView's identity + // changes. + const navigateToViewRef = useRef(navigateToView) + useEffect(() => { navigateToViewRef.current = navigateToView }, [navigateToView]) + + useEffect(() => { + const handle = (url: string) => { + const view = parseDeepLink(url) + if (view) void navigateToViewRef.current(view) + } + void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => { + if (url) handle(url) + }) + return window.ipc.on('app:openUrl', ({ url }) => handle(url)) + }, []) + + // Triggered by main when the user clicks a calendar-meeting notification. + // Reuses the same flow as the in-app "Join meeting & take notes" button. + // When `openMeeting` is true, also opens the meeting URL in the system browser. + useEffect(() => { + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { + const e = event as { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + if (!e || typeof e !== 'object') return + const conferenceLink = extractConferenceLink(e as Record) + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) + } + window.__pendingCalendarEvent = { + summary: e.summary, + start: e.start, + end: e.end, + location: e.location, + htmlLink: e.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) + }) + }, []) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) @@ -3240,7 +3521,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3256,16 +3537,11 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) - // Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode). - // If an editor tab is currently active, capture cursor context so Chat mode shows the - // note + line as a removable chip. + // Keyboard shortcut: Cmd+K / Ctrl+K opens the search palette (search-only). useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault() - const activeId = activeFileTabIdRef.current - const handle = activeId ? editorRefsByTabId.current.get(activeId) : null - setPaletteContext(handle?.getCursorContext() ?? null) setIsSearchOpen(true) } } @@ -3318,15 +3594,17 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen ? SUGGESTED_TOPICS_TAB_PATH + : isBackgroundAgentsOpen + ? BACKGROUND_AGENTS_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3381,7 +3659,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3406,7 +3684,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3528,14 +3806,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4076,7 +4354,13 @@ function App() { state={toToolState(item.status)} /> - + {item.streamingOutput ? ( + + + + ) : ( + + )} ) @@ -4119,7 +4403,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4136,7 +4420,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4169,7 +4453,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4180,7 +4464,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4204,14 +4488,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4235,10 +4519,14 @@ function App() { meetingSummarizing={meetingSummarizing} meetingAvailable={voiceAvailable} onToggleMeeting={() => { void handleToggleMeeting() }} + isSearchOpen={isSearchOpen} + isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} + isBackgroundAgentsOpen={isBackgroundAgentsOpen} + onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && ( + +

+ Notes that contain tracks. Toggle a note inactive to pause every background agent in it. +

+ +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+
+ +
+

{error}

+
+ ) : notes.length === 0 ? ( +
+
+ +
+

+ No notes with background agents yet. +

+
+ ) : ( +
+ + + + + + + + + + + {notes.map((note) => { + const isUpdating = updatingPaths.has(note.path) + return ( + + + + + + + ) + })} + +
NoteCreated dateLast ranState
+
+
+ + + {note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'} + +
+
+ {stripKnowledgePrefix(note.path)} +
+
+
+ {formatDateLabel(note.createdAt)} + + {formatDateTimeLabel(note.lastRunAt)} + +
+ {isUpdating ? ( + + ) : ( +
+
+
+ )} +
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx index 8777c035..a1270706 100644 --- a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx @@ -49,6 +49,7 @@ const BLOCKING_OVERLAY_SLOTS = new Set([ interface BrowserPaneProps { onClose: () => void + forceHidden?: boolean } const getActiveTab = (state: BrowserState) => @@ -85,7 +86,7 @@ const getBrowserTabTitle = (tab: BrowserTabState) => { } } -export function BrowserPane({ onClose }: BrowserPaneProps) { +export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) { const [state, setState] = useState(EMPTY_STATE) const [addressValue, setAddressValue] = useState('') @@ -175,6 +176,12 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { }, []) const syncView = useCallback(() => { + if (forceHidden) { + lastBoundsRef.current = null + setViewVisible(false) + return null + } + const doc = viewportRef.current?.ownerDocument if (doc && hasBlockingOverlay(doc)) { lastBoundsRef.current = null @@ -191,7 +198,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { pushBounds(bounds) setViewVisible(true) return bounds - }, [measureBounds, pushBounds, setViewVisible]) + }, [forceHidden, measureBounds, pushBounds, setViewVisible]) useEffect(() => { syncView() diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index e1fb950f..9d552905 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -10,8 +10,10 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCog, Globe, Headphones, + ImagePlus, LoaderIcon, Mic, Plus, @@ -23,8 +25,10 @@ import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -169,6 +173,7 @@ function ChatInputInner({ const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [workDir, setWorkDir] = useState(null) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -251,6 +256,55 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) + // Load currently configured work directory + const loadWorkDir = useCallback(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' }) + const parsed = JSON.parse(result.data) + const value = typeof parsed?.path === 'string' ? parsed.path.trim() : '' + setWorkDir(value || null) + } catch { + setWorkDir(null) + } + }, []) + + useEffect(() => { + loadWorkDir() + }, [isActive, loadWorkDir]) + + const handleSetWorkDir = useCallback(async () => { + try { + const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', { + title: 'Choose work directory', + defaultPath: workDir ?? undefined, + }) + if (!chosen) return + await window.ipc.invoke('workspace:writeFile', { + path: 'config/workdir.json', + data: JSON.stringify({ path: chosen }, null, 2), + }) + setWorkDir(chosen) + toast.success(`Work directory set: ${chosen}`) + } catch (err) { + console.error('Failed to set work directory', err) + toast.error('Failed to set work directory') + } + }, [workDir]) + + const handleClearWorkDir = useCallback(async () => { + try { + await window.ipc.invoke('workspace:writeFile', { + path: 'config/workdir.json', + data: JSON.stringify({}, null, 2), + }) + setWorkDir(null) + toast.success('Work directory cleared') + } catch (err) { + console.error('Failed to clear work directory', err) + toast.error('Failed to clear work directory') + } + }, []) + // Check search tool availability (exa or signed-in via gateway) useEffect(() => { const checkSearch = async () => { @@ -484,14 +538,53 @@ function ChatInputInner({ />
- + + + + + + fileInputRef.current?.click()}> + + Add files or photos + + { void handleSetWorkDir() }}> + + {workDir ? 'Change work directory' : 'Set work directory'} + + {workDir && ( + <> + + { void handleClearWorkDir() }}> + + Clear work directory + + + )} + + + {workDir && ( + + + + + + Work directory: {workDir} + + + )} {searchAvailable && ( searchEnabled ? ( + +
+ + + ) +} diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index 72b1cb35..c87ff068 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -29,6 +29,7 @@ import { FileTextIcon, FileIcon, FileTypeIcon, + Radio, } from 'lucide-react' import { DropdownMenu, @@ -42,6 +43,7 @@ interface EditorToolbarProps { onSelectionHighlight?: (range: { from: number; to: number } | null) => void onImageUpload?: (file: File) => Promise | void onExport?: (format: 'md' | 'pdf' | 'docx') => void + onOpenTracks?: () => void } export function EditorToolbar({ @@ -49,6 +51,7 @@ export function EditorToolbar({ onSelectionHighlight, onImageUpload, onExport, + onOpenTracks, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -385,6 +388,19 @@ export function EditorToolbar({ )} + + {/* Tracks — pushed to far right */} + {onOpenTracks && ( + + )} ) } diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx index 0ceb2c76..cc7aec0b 100644 --- a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] { return Object.entries(record).map(([key, value]) => ({ key, value })) } -function fieldsToRaw(fields: FieldEntry[]): string | null { +function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null { const record: Record = {} for (const { key, value } of fields) { if (key.trim()) record[key.trim()] = value } - return buildFrontmatter(record) + return buildFrontmatter(record, preserveRaw) } export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { @@ -45,10 +45,12 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro }, [editingNewKey]) const commit = useCallback((updated: FieldEntry[]) => { - const newRaw = fieldsToRaw(updated) + // Use the latest raw seen as the preserve-source so structured keys + // (like `track:`) survive a round-trip through this UI. + const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current) lastCommittedRaw.current = newRaw onRawChange(newRaw) - }, [onRawChange]) + }, [onRawChange, raw]) // For scalar fields: update local state immediately, commit on blur const updateLocalValue = useCallback((index: number, newValue: string) => { diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index e97f7c6e..7e34353d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -11,16 +11,14 @@ import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table' import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' -import { TrackBlockExtension } from '@/extensions/track-block' import { PromptBlockExtension } from '@/extensions/prompt-block' -import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' import { IframeBlockExtension } from '@/extensions/iframe-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' -import { EmailBlockExtension } from '@/extensions/email-block' +import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' @@ -48,36 +46,6 @@ function preprocessMarkdown(markdown: string): string { }) } -// Convert track-target open/close HTML comment markers into placeholder divs -// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom -// nodes. Content *between* the markers is left untouched — tiptap-markdown -// parses it naturally as whatever it is (paragraphs, lists, custom-block -// fences, etc.), all rendered live by the existing extension set. -// -// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag -// line until a blank line terminates it, and markdown inline rules (bold, -// italics, links) don't apply inside the block. Without surrounding blank -// lines, the line right after our placeholder div gets absorbed as HTML and -// its markdown is not parsed. -// -// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n` -// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks -// on save; a `\n?` regex on reload would only consume one of those two -// newlines, so every cycle would add a net newline on each side of every -// marker — causing tracks running on an open note to steadily inflate the -// file with blank lines around target regions. -function preprocessTrackTargets(md: string): string { - return md - .replace( - /\n*\n*/g, - (_m, id: string) => `\n\n
\n\n`, - ) - .replace( - /\n*\n*/g, - (_m, id: string) => `\n\n
\n\n`, - ) -} - // Post-process to clean up any zero-width spaces in the output function postprocessMarkdown(markdown: string): string { // Remove lines that contain only the zero-width space marker @@ -189,12 +157,6 @@ function blockToMarkdown(node: JsonNode): string { return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' case 'promptBlock': return '```prompt\n' + (node.attrs?.data as string || '') + '\n```' - case 'trackBlock': - return '```track\n' + (node.attrs?.data as string || '') + '\n```' - case 'trackTargetOpen': - return `` - case 'trackTargetClose': - return `` case 'imageBlock': return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' case 'embedBlock': @@ -697,22 +659,20 @@ export const MarkdownEditor = forwardRef { + ? (path: string) => { void wikiLinks.onCreate(path) } : undefined, @@ -1099,9 +1059,7 @@ export const MarkdownEditor = forwardRef s.split('\n').map(line => line.trimEnd()).join('\n').trim() if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - // Pre-process to preserve blank lines, then wrap track-target comment - // regions into placeholder divs so TrackTargetExtension can pick them up. - const preprocessed = preprocessMarkdown(preprocessTrackTargets(content)) + const preprocessed = preprocessMarkdown(content) // Treat tab-open content as baseline: do not add hydration to undo history. editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false @@ -1471,6 +1429,11 @@ export const MarkdownEditor = forwardRef { + window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', { + detail: { filePath: notePath }, + })) + } : undefined} /> {(frontmatter !== undefined) && onFrontmatterChange && ( (null) - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed — flags are seeded false and + // never flipped. Kept here so legacy gating expressions still type-check. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) @@ -151,25 +151,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { setProvidersLoading(false) } } - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } + // (Composio Gmail/Calendar flag fetches removed — sync was deleted.) loadProviders() - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -622,12 +605,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Falls back to BYOK modal + // for not-signed-in users. (Mirrors useConnectors.handleConnect.) + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index edb3616b..b06ec862 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -66,16 +66,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Inline upsell callout dismissed const [upsellDismissed, setUpsellDismissed] = useState(false) - // Composio/Gmail state (used when signed in with Rowboat account) - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed — flags are seeded false and + // never flipped. Kept here so legacy gating expressions still type-check. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) @@ -123,25 +123,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setProvidersLoading(false) } } - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } + // (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.) loadProviders() - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -539,17 +522,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const cleanup = window.ipc.on('oauth:didConnect', async (event) => { if (event.provider === 'rowboat' && event.success) { - // Re-check composio flags now that the account is connected - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (error) { - console.error('Failed to re-check composio flags:', error) - } + // (Composio Gmail/Calendar flag re-check removed — sync was deleted.) setCurrentStep(2) // Go to Connect Accounts } }) @@ -609,12 +582,20 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Falls back to BYOK modal + // for not-signed-in users. (Mirrors useConnectors.handleConnect.) + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 66a37802..56f0875a 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import posthog from 'posthog-js' import * as analytics from '@/lib/analytics' -import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react' +import { FileTextIcon, MessageSquareIcon } from 'lucide-react' import { CommandDialog, CommandInput, @@ -22,13 +22,14 @@ interface SearchResult { } type SearchType = 'knowledge' | 'chat' -type Mode = 'chat' | 'search' function activeTabToTypes(section: ActiveSection): SearchType[] { if (section === 'knowledge') return ['knowledge'] - return ['chat'] // "tasks" tab maps to chat + return ['chat'] } +// Retained for any remaining programmatic Copilot entry points (background-agent +// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot. export type CommandPaletteContext = { path: string lineNumber: number @@ -43,12 +44,8 @@ export type CommandPaletteMention = { interface CommandPaletteProps { open: boolean onOpenChange: (open: boolean) => void - // Search mode onSelectFile: (path: string) => void onSelectRun: (runId: string) => void - // Chat mode - initialContext?: CommandPaletteContext | null - onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void } export function CommandPalette({ @@ -56,14 +53,8 @@ export function CommandPalette({ onOpenChange, onSelectFile, onSelectRun, - initialContext, - onChatSubmit, }: CommandPaletteProps) { const { activeSection } = useSidebarSection() - const [mode, setMode] = useState('chat') - const [chatInput, setChatInput] = useState('') - const [contextChip, setContextChip] = useState(null) - const chatInputRef = useRef(null) const searchInputRef = useRef(null) const [query, setQuery] = useState('') @@ -74,45 +65,23 @@ export function CommandPalette({ ) const debouncedQuery = useDebounce(query, 250) - // On open: always reset to Chat mode (per spec — no mode persistence), sync context chip - // and reset search filters. + // Sync filters and clear query when the dialog opens. useEffect(() => { if (open) { - setMode('chat') - setChatInput('') - setContextChip(initialContext ?? null) + setQuery('') setActiveTypes(new Set(activeTabToTypes(activeSection))) } - }, [open, activeSection, initialContext]) + }, [open, activeSection]) - // Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't - // swallow it. Only fires while the dialog is open. useEffect(() => { if (!open) return - const handler = (e: KeyboardEvent) => { - if (e.key !== 'Tab') return - if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return - e.preventDefault() - e.stopPropagation() - setMode(prev => (prev === 'chat' ? 'search' : 'chat')) - } - document.addEventListener('keydown', handler, true) - return () => document.removeEventListener('keydown', handler, true) + searchInputRef.current?.focus() }, [open]) - // Refocus the appropriate input on mode change so the user can start typing immediately. - useEffect(() => { - if (!open) return - const target = mode === 'chat' ? chatInputRef : searchInputRef - target.current?.focus() - }, [open, mode]) - const toggleType = useCallback((type: SearchType) => { setActiveTypes(new Set([type])) }, []) - // Search query effect (only meaningful while in search mode, but the debounce keeps running - // harmlessly otherwise — empty query skips the IPC call below). useEffect(() => { if (!debouncedQuery.trim()) { setResults([]) @@ -133,25 +102,19 @@ export function CommandPalette({ }) .catch((err) => { console.error('Search failed:', err) - if (!cancelled) { - setResults([]) - } + if (!cancelled) setResults([]) }) .finally(() => { - if (!cancelled) { - setIsSearching(false) - } + if (!cancelled) setIsSearching(false) }) return () => { cancelled = true } }, [debouncedQuery, activeTypes]) - // Reset transient state on close. useEffect(() => { if (!open) { setQuery('') setResults([]) - setChatInput('') } }, [open]) @@ -164,20 +127,6 @@ export function CommandPalette({ } }, [onOpenChange, onSelectFile, onSelectRun]) - const submitChat = useCallback(() => { - const text = chatInput.trim() - if (!text && !contextChip) return - const mention: CommandPaletteMention | null = contextChip - ? { - path: contextChip.path, - displayName: deriveDisplayName(contextChip.path), - lineNumber: contextChip.lineNumber, - } - : null - onChatSubmit(text, mention) - onOpenChange(false) - }, [chatInput, contextChip, onChatSubmit, onOpenChange]) - const knowledgeResults = results.filter(r => r.type === 'knowledge') const chatResults = results.filter(r => r.type === 'chat') @@ -185,178 +134,77 @@ export function CommandPalette({ - {/* Mode strip */} +
- setMode('chat')} - icon={} - label="Chat" - /> - setMode('search')} + toggleType('knowledge')} icon={} - label="Search" + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" /> - Tab to switch
- - {mode === 'chat' ? ( -
- setChatInput(e.target.value)} - onKeyDown={(e) => { - // cmdk's Command component intercepts Enter for item selection — stop it - // before bubbling so we control the chat submit ourselves. - if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { - e.preventDefault() - e.stopPropagation() - submitChat() - } - }} - placeholder="Ask copilot anything…" - autoFocus - className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground" - /> - {contextChip && ( -
- - - {deriveDisplayName(contextChip.path)} - · Line {contextChip.lineNumber} - - - Enter to send -
- )} - {!contextChip && ( -
- Enter to send -
- )} -
- ) : ( - <> - -
- toggleType('knowledge')} - icon={} - label="Knowledge" - /> - toggleType('chat')} - icon={} - label="Chats" - /> -
- - {!query.trim() && ( - Type to search... - )} - {query.trim() && !isSearching && results.length === 0 && ( - No results found. - )} - {knowledgeResults.length > 0 && ( - - {knowledgeResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} - {chatResults.length > 0 && ( - - {chatResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} -
- - )} + + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
) } -// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette. -export const SearchDialog = CommandPalette - -function deriveDisplayName(path: string): string { - const base = path.split('/').pop() ?? path - return base.replace(/\.md$/, '') -} - -function ModeButton({ - active, - onClick, - icon, - label, -}: { - active: boolean - onClick: () => void - icon: React.ReactNode - label: string -}) { - return ( - - ) -} - function FilterToggle({ active, onClick, @@ -370,17 +218,19 @@ function FilterToggle({ }) { return ( ) } + +// Back-compat export: thin alias to CommandPalette. +export const SearchDialog = CommandPalette diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 41d6b622..dc49307c 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -156,6 +156,28 @@ const SERVICE_LABELS: Record = { granola: "Syncing Granola", graph: "Updating knowledge", voice_memo: "Processing voice memo", + email_labeling: "Labeling emails", + note_tagging: "Tagging notes", + agent_notes: "Updating agent notes", +} + +function summarizeServiceError(error: string): string { + const firstLine = error.split("\n").find((line) => line.trim().length > 0) + return firstLine?.trim() || error.trim() +} + +function collectServiceErrors(events: ServiceEventType[]): Map { + const errors = new Map() + for (const event of events) { + if (event.type === "error") { + errors.set(event.service, summarizeServiceError(event.error)) + continue + } + if (event.type === "run_complete" && event.outcome !== "error") { + errors.delete(event.service) + } + } + return errors } type TasksActions = { @@ -186,10 +208,14 @@ type SidebarContentPanelProps = { meetingSummarizing?: boolean meetingAvailable?: boolean onToggleMeeting?: () => void + isSearchOpen?: boolean + isMeetingActionActive?: boolean isBrowserOpen?: boolean onToggleBrowser?: () => void isSuggestedTopicsOpen?: boolean onOpenSuggestedTopics?: () => void + isBackgroundAgentsOpen?: boolean + onOpenBackgroundAgents?: () => void } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -225,6 +251,7 @@ function formatRunTime(ts: string): string { function SyncStatusBar() { const { state } = useSidebar() const [activeServices, setActiveServices] = useState>(new Map()) + const [serviceErrors, setServiceErrors] = useState>(new Map()) const [popoverOpen, setPopoverOpen] = useState(false) const [logEvents, setLogEvents] = useState([]) const [logLoading, setLogLoading] = useState(false) @@ -258,11 +285,25 @@ function SyncStatusBar() { next.delete(nextEvent.runId) return next }) + if (nextEvent.outcome !== 'error') { + setServiceErrors((prev) => { + if (!prev.has(nextEvent.service)) return prev + const next = new Map(prev) + next.delete(nextEvent.service) + return next + }) + } const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) if (existingTimeout) { clearTimeout(existingTimeout) runTimeoutsRef.current.delete(nextEvent.runId) } + } else if (nextEvent.type === 'error') { + setServiceErrors((prev) => { + const next = new Map(prev) + next.set(nextEvent.service, summarizeServiceError(nextEvent.error)) + return next + }) } }) return cleanup @@ -296,10 +337,14 @@ function SyncStatusBar() { // skip malformed lines } } + setServiceErrors(collectServiceErrors(parsed)) // Newest first, limit to 1000 setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS)) } catch { - if (!cancelled) setLogEvents([]) + if (!cancelled) { + setLogEvents([]) + setServiceErrors(new Map()) + } } finally { if (!cancelled) setLogLoading(false) } @@ -310,12 +355,19 @@ function SyncStatusBar() { const isSyncing = activeServices.size > 0 const isCollapsed = state === "collapsed" + const errorEntries = Array.from(serviceErrors.entries()) + const primaryErrorService = errorEntries[0]?.[0] ?? null + const hasServiceErrors = errorEntries.length > 0 // Build status label from active services const activeServiceNames = [...new Set(activeServices.values())] const statusLabel = isSyncing ? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ") - : "All caught up" + : hasServiceErrors + ? errorEntries.length === 1 + ? `${SERVICE_LABELS[primaryErrorService ?? ""] || primaryErrorService} failed` + : "Recent sync issues" + : "All caught up" return ( <> @@ -333,11 +385,16 @@ function SyncStatusBar() { )} + {onOpenBackgroundAgents && ( + + )} diff --git a/apps/x/apps/renderer/src/components/terminal-output.tsx b/apps/x/apps/renderer/src/components/terminal-output.tsx new file mode 100644 index 00000000..587616c8 --- /dev/null +++ b/apps/x/apps/renderer/src/components/terminal-output.tsx @@ -0,0 +1,24 @@ +import React, { useMemo } from 'react' +import { processTerminalOutput, spanStyleToCSS } from '../lib/terminal-output' + +export function TerminalOutput({ raw }: { raw: string }) { + const lines = useMemo(() => processTerminalOutput(raw), [raw]) + + return ( + <> + {lines.map((line, lineIdx) => ( + + {lineIdx > 0 && '\n'} + {line.spans.map((span, spanIdx) => { + const css = spanStyleToCSS(span.style) + return css ? ( + {span.text} + ) : ( + {span.text} + ) + })} + + ))} + + ) +} diff --git a/apps/x/apps/renderer/src/components/track-modal.tsx b/apps/x/apps/renderer/src/components/track-modal.tsx deleted file mode 100644 index a4c0b512..00000000 --- a/apps/x/apps/renderer/src/components/track-modal.tsx +++ /dev/null @@ -1,530 +0,0 @@ -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 = { - '* * * * *': 'Every minute', - '*/5 * * * *': 'Every 5 minutes', - '*/15 * * * *': 'Every 15 minutes', - '*/30 * * * *': 'Every 30 minutes', - '0 * * * *': 'Hourly', - '0 */2 * * *': 'Every 2 hours', - '0 */6 * * *': 'Every 6 hours', - '0 */12 * * *': 'Every 12 hours', - '0 0 * * *': 'Daily at midnight', - '0 8 * * *': 'Daily at 8 AM', - '0 9 * * *': 'Daily at 9 AM', - '0 12 * * *': 'Daily at noon', - '0 18 * * *': 'Daily at 6 PM', - '0 9 * * 1-5': 'Weekdays at 9 AM', - '0 17 * * 1-5': 'Weekdays at 5 PM', - '0 0 * * 0': 'Sundays at midnight', - '0 0 * * 1': 'Mondays at midnight', - '0 0 1 * *': 'First of each month', -} - -function describeCron(expr: string): string { - return CRON_PHRASES[expr.trim()] ?? expr -} - -type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt' -type ScheduleSummary = { icon: ScheduleIconKind; text: string } - -function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary { - if (!schedule) return { icon: 'bolt', text: 'Manual only' } - if (schedule.type === 'once') { - return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` } - } - if (schedule.type === 'cron') { - return { icon: 'timer', text: describeCron(schedule.expression) } - } - if (schedule.type === 'window') { - return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` } - } - return { icon: 'calendar', text: 'Scheduled' } -} - -function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) { - if (icon === 'timer') return - if (icon === 'calendar' || icon === 'target') return - return -} - -// --------------------------------------------------------------------------- -// Modal -// --------------------------------------------------------------------------- - -type Tab = 'what' | 'when' | 'event' | 'details' - -export function TrackModal() { - const [open, setOpen] = useState(false) - const [detail, setDetail] = useState(null) - const [yaml, setYaml] = useState('') - const [loading, setLoading] = useState(false) - const [activeTab, setActiveTab] = useState('what') - const [editingRaw, setEditingRaw] = useState(false) - const [rawDraft, setRawDraft] = useState('') - const [showAdvanced, setShowAdvanced] = useState(false) - const [confirmingDelete, setConfirmingDelete] = useState(false) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) - const textareaRef = useRef(null) - - // Listen for the open event and seed modal state. - useEffect(() => { - const handler = (e: Event) => { - const ev = e as CustomEvent - const d = ev.detail - if (!d?.trackId || !d?.filePath) return - setDetail(d) - setYaml(d.initialYaml ?? '') - setActiveTab('what') - setEditingRaw(false) - setRawDraft('') - setShowAdvanced(false) - setConfirmingDelete(false) - setError(null) - setOpen(true) - void fetchFresh(d) - } - window.addEventListener('rowboat:open-track-modal', handler as EventListener) - return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => { - try { - setLoading(true) - const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) }) - if (res?.success && res.yaml) { - setYaml(res.yaml) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setLoading(false) - } - }, []) - - const track = useMemo | null>(() => { - if (!yaml) return null - try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null } - }, [yaml]) - - const trackId = track?.trackId ?? detail?.trackId ?? '' - const instruction = track?.instruction ?? '' - const active = track?.active ?? true - const schedule = track?.schedule - const eventMatchCriteria = track?.eventMatchCriteria ?? '' - const lastRunAt = track?.lastRunAt ?? '' - const lastRunId = track?.lastRunId ?? '' - const lastRunSummary = track?.lastRunSummary ?? '' - const model = track?.model ?? '' - const provider = track?.provider ?? '' - const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule]) - const triggerType: 'scheduled' | 'event' | 'manual' = - schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual' - - const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : '' - - const allTrackStatus = useTrackStatus() - const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const } - const isRunning = runState.status === 'running' - - useEffect(() => { - if (editingRaw && textareaRef.current) { - textareaRef.current.focus() - textareaRef.current.setSelectionRange( - textareaRef.current.value.length, - textareaRef.current.value.length, - ) - } - }, [editingRaw]) - - const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [ - { key: 'what', label: 'What to track', visible: true }, - { key: 'when', label: 'When to run', visible: !!schedule }, - { key: 'event', label: 'Event matching', visible: !!eventMatchCriteria }, - { key: 'details', label: 'Details', visible: true }, - ] - const shown = visibleTabs.filter(t => t.visible) - - useEffect(() => { - if (!shown.some(t => t.key === activeTab)) setActiveTab('what') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schedule, eventMatchCriteria]) - - // ------------------------------------------------------------------------- - // IPC-backed mutations - // ------------------------------------------------------------------------- - - const runUpdate = useCallback(async (updates: Record) => { - if (!detail) return - setSaving(true) - setError(null) - try { - const res = await window.ipc.invoke('track:update', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - updates, - }) - if (res?.success && res.yaml) { - setYaml(res.yaml) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [detail]) - - const handleToggleActive = useCallback(() => { - void runUpdate({ active: !active }) - }, [active, runUpdate]) - - const handleRun = useCallback(async () => { - if (!detail || isRunning) return - try { - await window.ipc.invoke('track:run', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - }) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - }, [detail, isRunning]) - - const handleSaveRaw = useCallback(async () => { - if (!detail) return - setSaving(true) - setError(null) - try { - const res = await window.ipc.invoke('track:replaceYaml', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - yaml: rawDraft, - }) - if (res?.success && res.yaml) { - setYaml(res.yaml) - setEditingRaw(false) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [detail, rawDraft]) - - const handleDelete = useCallback(async () => { - if (!detail) return - setSaving(true) - setError(null) - try { - const res = await window.ipc.invoke('track:delete', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - }) - if (res?.success) { - // Tell the editor to remove the node so Tiptap's next save doesn't - // re-create the track block on disk. - try { detail.onDeleted() } catch { /* editor may have unmounted */ } - setOpen(false) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [detail]) - - const handleEditWithCopilot = useCallback(() => { - if (!detail) return - window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', { - detail: { - trackId: detail.trackId, - filePath: detail.filePath, - }, - })) - setOpen(false) - }, [detail]) - - if (!detail) return null - - return ( - - -
-
-
- -
-
- - - {trackId || 'Track'} - - - - {scheduleSummary.text} - {eventMatchCriteria && triggerType === 'scheduled' && ( - · also event-driven - )} - - -
-
-
- -
-
- - {/* Tabs */} -
- {shown.map(tab => ( - - ))} -
- - {/* Body */} -
- {loading &&
Loading latest…
} - - {activeTab === 'what' && ( -
- {instruction - ? {instruction} - : No instruction set.} -
- )} - - {activeTab === 'when' && schedule && ( -
-
- - {scheduleSummary.text} -
-
-
Type
{schedule.type}
- {schedule.type === 'cron' && ( - <> -
Expression
{schedule.expression}
- - )} - {schedule.type === 'window' && ( - <> -
Expression
{schedule.cron}
-
Window
{schedule.startTime} – {schedule.endTime}
- - )} - {schedule.type === 'once' && ( - <> -
Runs at
{formatDateTime(schedule.runAt)}
- - )} -
-
- )} - - {activeTab === 'event' && ( -
- {eventMatchCriteria - ? {eventMatchCriteria} - : No event matching set.} -
- )} - - {activeTab === 'details' && ( -
-
-
Track ID
{trackId}
-
File
{detail.filePath}
-
Status
{active ? 'Active' : 'Paused'}
- {model && (<> -
Model
{model}
- )} - {provider && (<> -
Provider
{provider}
- )} - {lastRunAt && (<> -
Last run
{formatDateTime(lastRunAt)}
- )} - {lastRunId && (<> -
Run ID
{lastRunId}
- )} - {lastRunSummary && (<> -
Summary
{lastRunSummary}
- )} -
-
- )} - - {/* Advanced (raw YAML) — all tabs */} -
- - {showAdvanced && ( -
-