mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
Compare commits
99 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6288f99a85 | ||
|
|
2f9ce051c0 | ||
|
|
89f6f80215 | ||
|
|
6d953428c4 | ||
|
|
c4888e2899 | ||
|
|
ec2e7d8145 | ||
|
|
aba65843c2 | ||
|
|
55490fa63c | ||
|
|
9ee42d2f75 | ||
|
|
95c313de89 | ||
|
|
fe5e67f810 | ||
|
|
65f8e9d678 | ||
|
|
4d160da105 | ||
|
|
6492cf65b5 | ||
|
|
7dcf8eea70 | ||
|
|
69e4f253dd | ||
|
|
af618155e1 | ||
|
|
d586f6bd8a | ||
|
|
f9ddc6549a | ||
|
|
41f783d504 | ||
|
|
f371cd4bb1 | ||
|
|
b01af12148 | ||
|
|
13fa80c687 | ||
|
|
e594b667bf | ||
|
|
c756e61d7a | ||
|
|
47d7100368 | ||
|
|
a9b4e06018 | ||
|
|
ab23cb4543 | ||
|
|
e3d2a0988b | ||
|
|
10995ebed6 | ||
|
|
8737605666 | ||
|
|
b7b84e94e0 | ||
|
|
dabca3da19 | ||
|
|
0bf7a55611 | ||
|
|
cc176898df | ||
|
|
66e22bd779 | ||
|
|
8e10d8bff3 | ||
|
|
c5ee363122 | ||
|
|
89f56a8059 | ||
|
|
385ed3377f | ||
|
|
60e5b2cbc7 | ||
|
|
a1e4002533 | ||
|
|
3b09296291 | ||
|
|
acff502f42 | ||
|
|
f5bba5e271 | ||
|
|
0250ca638e | ||
|
|
d9d936b7e8 | ||
|
|
49a50279da | ||
|
|
4b7911c8ea | ||
|
|
a4cd6abb3a | ||
|
|
b3519433eb | ||
|
|
b24113b78e | ||
|
|
0d9cf71947 | ||
|
|
ede98f5378 | ||
|
|
754561d893 | ||
|
|
9014c79f2c | ||
|
|
62a07618e0 | ||
|
|
eb6a7ac466 | ||
|
|
db6757514c | ||
|
|
4709e6eb89 | ||
|
|
a48887da61 | ||
|
|
d515c423ee | ||
|
|
a18f5dc3dd | ||
|
|
d6651c4bf8 | ||
|
|
0e3d058c29 | ||
|
|
3630032d21 | ||
|
|
37c1627d79 | ||
|
|
0bb58e55ac | ||
|
|
f26d57e8eb | ||
|
|
5e47bd4309 | ||
|
|
72ed4bd6d9 | ||
|
|
e54b5cd27f | ||
|
|
7b119fbfcd | ||
|
|
c6083de054 | ||
|
|
c382e3ee8a | ||
|
|
d4850dace7 | ||
|
|
a76f8bae14 | ||
|
|
0bd234ddf6 | ||
|
|
93feee15a0 | ||
|
|
1c2b2ac1fc | ||
|
|
9ed54e2b94 | ||
|
|
17afc935bf | ||
|
|
de176ec458 | ||
|
|
4ca03daa4c | ||
|
|
0dff57e8f7 | ||
|
|
43c1ba719f | ||
|
|
f14f3b0347 | ||
|
|
d42fb26bcc | ||
|
|
caf00fae0c | ||
|
|
bdf270b7a1 | ||
|
|
0bb256879c | ||
|
|
75842fa06b | ||
|
|
f4dbb58a77 | ||
|
|
5c4aa77255 | ||
|
|
51f2ad6e8a | ||
|
|
15567cd1dd | ||
|
|
c81d3cb27b | ||
|
|
32b6b2f1c0 | ||
|
|
0f051ea467 |
181 changed files with 19709 additions and 6330 deletions
|
|
@ -108,7 +108,8 @@ 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` |
|
||||
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
|
||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
|
|||
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
|
|
|
|||
158
apps/x/ANALYTICS.md
Normal file
158
apps/x/ANALYTICS.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Analytics
|
||||
|
||||
> PostHog instrumentation for `apps/x`. We capture LLM token usage (broken down by feature) and identity/auth events. Renderer (`posthog-js`) and main (`posthog-node`) share one stable distinct_id and one identified user, so events from either process resolve to the same person.
|
||||
|
||||
## Identity model
|
||||
|
||||
- **Anonymous distinct_id** = `installationId` from `~/.rowboat/config/installation.json` (auto-generated on first run; see `packages/core/src/analytics/installation.ts`).
|
||||
- Renderer fetches it from main on startup via the `analytics:bootstrap` IPC channel and passes it as PostHog's `bootstrap.distinctID`. Main uses it directly in `posthog-node`.
|
||||
- **On rowboat sign-in**: `posthog.identify(rowboatUserId)` runs in **both** processes.
|
||||
- Main does it from `apps/main/src/oauth-handler.ts:285` (after `getBillingInfo()` resolves) — this is the load-bearing call, since main always runs.
|
||||
- Renderer mirrors via `apps/renderer/src/hooks/useAnalyticsIdentity.ts` listening on the `oauth:didConnect` IPC event.
|
||||
- Main also calls `alias()` so events emitted under the anonymous installation_id are linked to the identified user retroactively.
|
||||
- **On every app startup**: main re-identifies if rowboat tokens exist (`packages/core/src/analytics/identify.ts`, called from `apps/main/src/main.ts` whenReady). Idempotent — PostHog merges person properties on duplicate identifies. This catches users who installed before analytics existed, and refreshes person properties (plan/status) on every launch.
|
||||
- **On rowboat sign-out**: `posthog.reset()` in both processes; future events resolve to the installation_id again.
|
||||
- **`email`** is set on `identify` from main only (sourced from `/v1/me`). Person properties are server-side, so the renderer's events resolve to the same record without redundantly setting it.
|
||||
|
||||
## Event catalog
|
||||
|
||||
### `llm_usage`
|
||||
|
||||
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run).
|
||||
|
||||
| Property | Type | Notes |
|
||||
|---|---|---|
|
||||
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
|
||||
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
|
||||
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
|
||||
| `model` | string | e.g. `claude-sonnet-4-6` |
|
||||
| `provider` | string | `rowboat` = cloud LLM gateway; otherwise the BYOK provider (`openai`, `anthropic`, `ollama`, etc.) |
|
||||
| `input_tokens` | number | |
|
||||
| `output_tokens` | number | |
|
||||
| `total_tokens` | number | |
|
||||
| `cached_input_tokens` | number? | When the provider reports it |
|
||||
| `reasoning_tokens` | number? | When the provider reports it |
|
||||
|
||||
#### Use-case taxonomy
|
||||
|
||||
Every `llm_usage` emit point in the codebase:
|
||||
|
||||
| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line |
|
||||
|---|---|---|---|---|
|
||||
| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) |
|
||||
| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` |
|
||||
| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` |
|
||||
| `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` |
|
||||
| `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) |
|
||||
| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site |
|
||||
| `live_note_agent` | `window` | yes | Pass 2 agent run — fired inside a configured time-of-day window | same call site |
|
||||
| `live_note_agent` | `event` | yes | Pass 2 agent run — Pass 1 routing flagged the note for an incoming event | same call site |
|
||||
| `meeting_note` | (none) | no | Meeting transcript summarizer (`generateText`) | `packages/core/src/knowledge/summarize_meeting.ts:161` |
|
||||
| `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) |
|
||||
| `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) |
|
||||
| `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) |
|
||||
| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) |
|
||||
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
|
||||
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
|
||||
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
|
||||
|
||||
##### `live_note_agent` sub-use-case shape
|
||||
|
||||
For the live-note feature specifically, `sub_use_case` discriminates **what kind of work happened**:
|
||||
|
||||
- `routing` — Pass 1 LLM classifier deciding which live notes might be relevant to an incoming event. One emit per Pass 1 batch.
|
||||
- `manual` / `cron` / `window` / `event` — Pass 2 agent run, tagged with the trigger that woke it up. The runner reads its `trigger` argument (`LiveNoteTriggerType`) and passes it directly as `subUseCase`, so dashboards can break runs down by trigger source.
|
||||
|
||||
This means a single end-to-end event flow emits both `routing` (Pass 1) and `event` (Pass 2). A scheduled cron fire emits only `cron`. A user clicking Run emits only `manual`. There is no separate "run" sub-use-case anymore — the trigger IS the sub-use-case for Pass 2.
|
||||
|
||||
`testModelConnection` in `packages/core/src/models/models.ts` is **not** instrumented (diagnostic only — would skew per-model counts).
|
||||
|
||||
### `user_signed_in`
|
||||
|
||||
Emitted when rowboat OAuth completes. Properties: `plan`, `status` (subscription state from `/v1/me`).
|
||||
|
||||
Emitted from **both** processes:
|
||||
- Main (`apps/main/src/oauth-handler.ts:290`) — always fires; load-bearing.
|
||||
- Renderer (`apps/renderer/src/hooks/useAnalyticsIdentity.ts:75`) — fires only when the renderer is open. Same distinct_id, so dedup is automatic in PostHog dashboards.
|
||||
|
||||
### `user_signed_out`
|
||||
|
||||
Emitted on rowboat disconnect. No properties. Followed immediately by `posthog.reset()`.
|
||||
|
||||
Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/useAnalyticsIdentity.ts:82`.
|
||||
|
||||
### Other events (pre-existing, not added by the LLM-usage work)
|
||||
|
||||
All in `apps/renderer/src/lib/analytics.ts`:
|
||||
|
||||
- `chat_session_created` — `{ run_id }`
|
||||
- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }`
|
||||
- `oauth_connected` / `oauth_disconnected` — `{ provider }`
|
||||
- `voice_input_started` — no properties
|
||||
- `search_executed` — `{ types: string[] }`
|
||||
- `note_exported` — `{ format }`
|
||||
|
||||
## Person properties
|
||||
|
||||
Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`.
|
||||
|
||||
| Property | Set by | Notes |
|
||||
|---|---|---|
|
||||
| `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations |
|
||||
| `plan`, `status` | main on identify | Subscription state |
|
||||
| `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production |
|
||||
| `signed_in` | renderer | `true` while rowboat OAuth is connected |
|
||||
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
|
||||
| `total_notes` | renderer (init) | Workspace size signal |
|
||||
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
|
||||
|
||||
## How to add a new event
|
||||
|
||||
1. **Naming**: `snake_case`, `[object]_[verb]` shape (e.g. `note_exported`, not `exportedNote`). Matches PostHog convention.
|
||||
2. **Pick the right helper**:
|
||||
- LLM token usage → `captureLlmUsage()` from `@x/core/dist/analytics/usage.js`. Always include `useCase`; add `subUseCase` if it refines an existing top-level case.
|
||||
- Anything else from main → `capture()` from `@x/core/dist/analytics/posthog.js`.
|
||||
- Anything else from renderer → add a typed wrapper to `apps/renderer/src/lib/analytics.ts` and call it from the UI code (don't call `posthog.capture()` directly from components).
|
||||
3. **If it's a new LLM call site**:
|
||||
- Goes through `createRun`? Pass `useCase` (and optionally `subUseCase`) to the create call. The runtime auto-emits at every `finish-step` — no further code needed.
|
||||
- Direct `generateText` / `generateObject`? Call `captureLlmUsage` after the call with `model`, `provider`, `usage` from the result.
|
||||
- Inside a builtin tool? Call `getCurrentUseCase()` from `analytics/use_case.ts` first — the parent run's tag is propagated via `AsyncLocalStorage`. Use `ctx?.useCase ?? 'copilot_chat'` as fallback.
|
||||
4. **Update this file in the same PR.** That's the contract — without it, dashboards and downstream consumers drift.
|
||||
|
||||
## How to add a new use-case sub-case
|
||||
|
||||
- **New `sub_use_case` under an existing top-level case**: just pick a string and add a row to the taxonomy table above. No code changes beyond the call site.
|
||||
- **New top-level `use_case`**: edit the `UseCase` enum in `packages/shared/src/runs.ts` and the matching `UseCase` type in `packages/core/src/analytics/use_case.ts`. Then update this doc.
|
||||
|
||||
## Configuration
|
||||
|
||||
PostHog credentials live in two env vars (also baked into the binary at packaging time — never set at runtime in distributed builds):
|
||||
|
||||
- `VITE_PUBLIC_POSTHOG_KEY` — project API key (e.g. `phc_xxx`). Public-facing — safe to commit if you'd rather hardcode.
|
||||
- `VITE_PUBLIC_POSTHOG_HOST` — e.g. `https://us.i.posthog.com`. Defaults to US cloud if unset.
|
||||
|
||||
Where they're consumed:
|
||||
- **Renderer** (Vite): `import.meta.env.VITE_PUBLIC_POSTHOG_*` — inlined at build time.
|
||||
- **Main** (esbuild via `apps/main/bundle.mjs`): inlined into `main.cjs` at packaging time using esbuild `define`. In dev (`npm run dev`), main reads them from `process.env` at runtime.
|
||||
|
||||
For GitHub Actions / packaged builds: set both as workflow env vars (from secrets) on the step that runs `npm run package` or `npm run make`. They'll be baked in.
|
||||
|
||||
If unset, analytics no-op silently — you'll see `[Analytics] POSTHOG_KEY not set; analytics disabled` in main-process logs.
|
||||
|
||||
`installationId`: stored in `~/.rowboat/config/installation.json`, generated on first run.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `packages/core/src/analytics/installation.ts` | Stable per-install distinct_id |
|
||||
| `packages/core/src/analytics/posthog.ts` | Main-process client (`capture`, `identify`, `reset`, `shutdown`) |
|
||||
| `packages/core/src/analytics/usage.ts` | `captureLlmUsage()` helper |
|
||||
| `packages/core/src/analytics/use_case.ts` | `AsyncLocalStorage` for tool-internal LLM call inheritance |
|
||||
| `apps/renderer/src/lib/analytics.ts` | Renderer event wrappers |
|
||||
| `apps/renderer/src/hooks/useAnalyticsIdentity.ts` | Renderer identify/reset on OAuth events |
|
||||
| `apps/main/src/oauth-handler.ts` | Main-side identify/reset/sign-in/sign-out events |
|
||||
| `apps/main/src/main.ts` | `before-quit` hook flushes queued events |
|
||||
| `packages/shared/src/ipc.ts` | `analytics:bootstrap` IPC channel definition |
|
||||
| `apps/main/src/ipc.ts` | `analytics:bootstrap` handler + forwards `userId` on `oauth:didConnect` |
|
||||
| `apps/main/bundle.mjs` | Bakes `POSTHOG_KEY`/`POSTHOG_HOST` into packaged `main.cjs` |
|
||||
408
apps/x/LIVE_NOTE.md
Normal file
408
apps/x/LIVE_NOTE.md
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# Live Notes
|
||||
|
||||
> A single `live:` frontmatter block that turns a markdown note into a self-updating artifact — refreshed on a schedule (cron / windows), in response to incoming events (Gmail, Calendar), or on demand.
|
||||
|
||||
A live note has exactly **one** `live:` block in its YAML frontmatter. The block carries a persistent **objective** (what the note should keep being), an optional **triggers** object (when the agent should fire), and runtime fields the system writes back. The body below the H1 is owned by the live-note agent — it freely synthesizes, dedupes, and reorganizes the content to satisfy the objective. A note with no `live:` key is just a static note.
|
||||
|
||||
**Example** (a note that shows the current Chicago time, refreshed hourly):
|
||||
|
||||
~~~markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
Show the current time in Chicago, IL in 12-hour format. Keep it as one
|
||||
short line, no extra prose.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
lastAttemptAt: "2026-05-08T15:00:00.123Z"
|
||||
lastRunAt: "2026-05-08T15:00:01.234Z"
|
||||
lastRunId: "..."
|
||||
lastRunSummary: "Updated — 3:00 PM, Central Time."
|
||||
lastRunError: null
|
||||
---
|
||||
|
||||
# Chicago time
|
||||
|
||||
3:00 PM, Central Time
|
||||
~~~
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Product Overview](#product-overview)
|
||||
2. [Architecture at a Glance](#architecture-at-a-glance)
|
||||
3. [Technical Flows](#technical-flows)
|
||||
4. [Schema Reference](#schema-reference)
|
||||
5. [Body Structure](#body-structure)
|
||||
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
|
||||
|
||||
### One note, one objective
|
||||
|
||||
A live note has at most one `live:` block. The block has exactly one `objective`. The objective can be long and cover multiple sub-topics — the agent treats the note holistically and is free to lay out the body however the objective suggests. **There is no second objective per note.** When the user asks Copilot to "also keep an eye on X" in an already-live note, Copilot is trained to extend the existing objective in natural language rather than fork a second block.
|
||||
|
||||
This is intentional: the user is *delegating awareness*, not configuring automations. Multiple agents per note led to ownership confusion, scope boundaries, and orchestration concerns that don't fit a personal-knowledge tool.
|
||||
|
||||
### Triggers
|
||||
|
||||
The `triggers` object has three independently optional sub-fields. Each one is its own channel; mix freely.
|
||||
|
||||
| Field | When it fires | Shape |
|
||||
|---|---|---|
|
||||
| **`cronExpr`** | At exact cron times | `cronExpr: "0 * * * *"` |
|
||||
| **`windows`** | Once per day per window, anywhere inside a time-of-day band | `windows: [{ startTime: "09:00", endTime: "12:00" }]` |
|
||||
| **`eventMatchCriteria`** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` |
|
||||
|
||||
A `triggers` block with no fields (or no `triggers` key at all) is **manual-only** — the agent fires only when the user clicks Run in the panel.
|
||||
|
||||
`cronExpr` enforces 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. `windows` are forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, the agent fires the moment the app is open. Each window's daily cycle is anchored at `startTime`.
|
||||
|
||||
The `once` trigger from the prior model has been **dropped** — it didn't fit the "ongoing awareness" framing.
|
||||
|
||||
### Creating a live note
|
||||
|
||||
Two paths, both producing identical on-disk YAML:
|
||||
|
||||
1. **Hand-written** — type the `live:` block directly into the note's frontmatter. 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 "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
|
||||
|
||||
When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block.
|
||||
|
||||
### Viewing and managing live notes
|
||||
|
||||
The editor toolbar has a Radio-icon button (right side) that opens the **Live Note panel** for the current note. The panel:
|
||||
|
||||
- **Empty state** (passive note) — "Make this note live" CTA that hands off to Copilot for the natural-language flow.
|
||||
- **Editor** — single panel with: objective textarea, triggers editor (cron / windows list / eventMatchCriteria, each independently shown via add/remove), status row (last-run summary + active toggle), Advanced (collapsed: model + provider), footer (Edit with Copilot · Save · Run now), and a danger-zone "Make passive" button.
|
||||
- **Status hook** — `useLiveNoteAgentStatus` subscribes to `live-note-agent:events` IPC; the Run button shows a spinner whenever the agent is running.
|
||||
|
||||
Every mutation 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 the runtime agent does
|
||||
|
||||
When a trigger fires, the live-note agent receives a short message:
|
||||
- The workspace-relative path to the note and a localized timestamp.
|
||||
- The objective.
|
||||
- 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").
|
||||
|
||||
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. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites.
|
||||
3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first.
|
||||
4. Never modify YAML frontmatter — that's owned by the user and the runtime.
|
||||
5. End with a 1-2 sentence summary stored as `lastRunSummary`.
|
||||
|
||||
The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP).
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
Editor toolbar Radio button ─click──► LiveNoteSidebar (React)
|
||||
│
|
||||
├──► IPC: live-note:get / set /
|
||||
│ setActive / delete / run
|
||||
│
|
||||
Backend (main process)
|
||||
├─ Scheduler loop (15 s) ──┐
|
||||
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
|
||||
└─ Builtin tool │ │
|
||||
run-live-note-agent ────┘ ▼
|
||||
workspace-readFile / -edit
|
||||
│
|
||||
▼
|
||||
body region(s) rewritten on disk
|
||||
frontmatter lastRun* patched
|
||||
```
|
||||
|
||||
**Single-writer invariant** — the renderer is never a file writer for the `live:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/live-note/fileops.ts` (`setLiveNote`, `patchLiveNote`, `setLiveNoteActive`, `deleteLiveNote`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `live:` 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-live-note-panel', { detail: { filePath } }))` is the sole entry point from editor toolbar → panel. `rowboat:open-copilot-edit-live-note` opens the Copilot sidebar with the note attached.
|
||||
|
||||
---
|
||||
|
||||
## Technical Flows
|
||||
|
||||
### Scheduling (cron / windows)
|
||||
|
||||
- **Module**: `packages/core/src/knowledge/live-note/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
|
||||
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, `fetchLiveNote(relPath)` for each.
|
||||
- For each note with a `live:` block where `active !== false`, `dueTimedTrigger(triggers, lastRunAt)` returns `'cron'`, `'window'`, or `null` — pure cycle check, no backoff. The scheduler then calls `backoffRemainingMs(lastAttemptAt)` separately so it can log "matched cron, backoff 4m remaining" rather than collapse the two reasons.
|
||||
- When due AND not in backoff, fire `runLiveNoteAgent(relPath, source)` where `source` is `'cron'` or `'window'` (the granular trigger surfaces all the way to the agent message — see Trigger granularity).
|
||||
- **Cycle anchoring** — anchored on `lastRunAt`, which is bumped only on *successful* completions. A failed run leaves the cycle unfired so the scheduler retries.
|
||||
- **Backoff** — `RETRY_BACKOFF_MS = 5 min`. If `lastAttemptAt` is within that window, the scheduler skips the note. Covers both in-flight runs (the in-memory concurrency guard handles the common case; backoff is the disk-persistent backstop) and post-failure storming. Manual runs (clicked Run) bypass this — they don't go through the scheduler.
|
||||
- **Cron grace** — `cronExpr` enforces a 2-minute grace; missed schedules are skipped, not replayed.
|
||||
- **Windows** have no grace — anywhere inside the band counts. A failed run inside the band leaves the window unfired; the next eligible tick (after backoff) retries.
|
||||
- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a *successful* 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** — `initLiveNoteScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initLiveNoteEventProcessor()`.
|
||||
|
||||
### Event pipeline
|
||||
|
||||
**Producers** — any data source that should feed live notes emits events:
|
||||
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
|
||||
- **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/live-note/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
|
||||
|
||||
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
|
||||
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
|
||||
2. `listEventEligibleLiveNotes()` scans every `.md` under `knowledge/`. Only notes where `live.active !== false` and `live.triggers?.eventMatchCriteria` is set are event-eligible.
|
||||
3. `findCandidates(event, eligible)` runs Pass 1 LLM routing (below).
|
||||
4. For each candidate, `runLiveNoteAgent(filePath, 'event', event.payload)` **sequentially** — preserves total ordering within the event.
|
||||
5. Enrich the event JSON with `processedAt`, `candidateFilePaths`, `runIds`, `error?`, then move to `events/done/<id>.json`.
|
||||
|
||||
**Pass 1 routing** (`routing.ts`):
|
||||
- **Short-circuit** — if `event.targetFilePath` is set (manual re-run events), skip the LLM and return that note directly.
|
||||
- Batches of `BATCH_SIZE = 20`.
|
||||
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `{ filePaths: string[] }`. Direct path-based dedup (no composite key needed since live-note is one-per-file).
|
||||
|
||||
**Pass 2 decision** happens inside the live-note agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body.
|
||||
|
||||
### Trigger granularity
|
||||
|
||||
Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | 'event'` — propagated end-to-end through `runLiveNoteAgent(filePath, trigger, context?)`, the `liveNoteBus` start event, and the `live-note-agent:events` IPC payload.
|
||||
|
||||
- The **scheduler** passes `'cron'` or `'window'` based on which sub-trigger `dueTimedTrigger` matched.
|
||||
- The **event processor** always passes `'event'`.
|
||||
- The **panel Run button** and the **`run-live-note-agent` builtin tool** both pass `'manual'`.
|
||||
|
||||
`buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context).
|
||||
|
||||
This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
|
||||
|
||||
### Run flow (`runLiveNoteAgent`)
|
||||
|
||||
Module: `packages/core/src/knowledge/live-note/runner.ts`.
|
||||
|
||||
1. **Concurrency guard** — static `runningLiveNotes: Set<string>` keyed by `filePath`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
|
||||
2. **Fetch live note** via `fetchLiveNote(filePath)`.
|
||||
3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff.
|
||||
4. **Create agent run** — `createRun({ agentId: 'live-note-agent' })`.
|
||||
5. **Bump `lastAttemptAt` + `lastRunId` immediately** (before the agent executes). `lastAttemptAt` is the disk-persistent backoff anchor — the scheduler suppresses fires within `RETRY_BACKOFF_MS` (5 min) of it, covering both in-flight runs and post-failure backoff. **`lastRunAt` is not touched here** — that field is the cycle anchor and should only move on success.
|
||||
6. **Emit `live_note_agent_start`** on the `liveNoteBus` with the trigger type (`manual` / `timed` / `event`).
|
||||
7. **Send agent message** built by `buildMessage(filePath, live, 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. **On success:** `patchLiveNote(filePath, { lastRunAt: now, lastRunSummary, lastRunError: undefined })`.
|
||||
11. **On failure:** `patchLiveNote(filePath, { lastRunError: msg })`. **`lastRunAt` and `lastRunSummary` are deliberately untouched** so the user keeps seeing the last good state in the UI, and the scheduler treats the cycle as unfired (windows will retry inside the same band, gated only by the 5-min backoff).
|
||||
12. **Emit `live_note_agent_complete`** with `summary` or `error`.
|
||||
13. **Cleanup**: `runningLiveNotes.delete(filePath)` in a `finally` block.
|
||||
|
||||
Returned to callers: `{ filePath, runId, action, contentBefore, contentAfter, summary, error? }`.
|
||||
|
||||
**Stops** — when the user clicks Stop in the panel, `live-note:stop` resolves the latest `lastRunId` and calls `runsCore.stop(runId, false)`. The runner's `waitForRunCompletion` throws, the failure branch records `lastRunError`, and the bus emits `complete` with the error. The cycle stays unfired (so the run is retried on the next tick after backoff expires) — exactly the same path as any other failure.
|
||||
|
||||
### IPC surface
|
||||
|
||||
| Channel | Caller → handler | Purpose |
|
||||
|---|---|---|
|
||||
| `live-note:run` | Renderer (panel Run button) | Fires `runLiveNoteAgent(..., 'manual')` |
|
||||
| `live-note:get` | Panel on open | Returns the parsed `LiveNote \| null` from frontmatter |
|
||||
| `live-note:set` | Panel save | Validates + writes the whole `live:` block |
|
||||
| `live-note:setActive` | Panel toggle | Flips `active` |
|
||||
| `live-note:delete` | Panel "Make passive" | Removes the entire `live:` block |
|
||||
| `live-note:stop` | Panel Stop button | Resolves the live block's `lastRunId` and calls `runsCore.stop(runId)` |
|
||||
| `live-note:listNotes` | Background-agents view | Lists all live notes with summary fields |
|
||||
| `live-note-agent:events` | Server → renderer (`webContents.send`) | Forwards `liveNoteBus` events to `useLiveNoteAgentStatus` |
|
||||
|
||||
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/live-note/fileops.ts`.
|
||||
|
||||
### Concurrency & FIFO guarantees
|
||||
|
||||
- **Per-note serialization** — the `runningLiveNotes` guard in `runner.ts`. A note is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`.
|
||||
- **Backend is single writer for `live:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `live:` 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 note 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/live-note.ts`:
|
||||
|
||||
- `LiveNoteSchema` — the entire `live:` block. Fields: `objective`, `active` (default true), `triggers?`, `model?`, `provider?`. **Runtime-managed (never hand-write):** `lastAttemptAt`, `lastRunAt`, `lastRunId`, `lastRunSummary`, `lastRunError`.
|
||||
- `TriggersSchema` — single object with three optional sub-fields: `cronExpr`, `windows`, `eventMatchCriteria`. Each window is `{ startTime, endTime }` (24-hour HH:MM, local).
|
||||
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidateFilePaths`, `runIds`, `error`) are populated when moving to `done/`.
|
||||
- `Pass1OutputSchema` — `{ filePaths: string[] }`.
|
||||
|
||||
The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` — so editing `LiveNoteSchema` propagates to the skill on the next build.
|
||||
|
||||
---
|
||||
|
||||
## Body Structure
|
||||
|
||||
The agent owns the entire body below the H1. There is **no formal section ownership** anymore — the agent edits, reorganizes, and dedupes freely.
|
||||
|
||||
The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/live-note/agent.ts`):
|
||||
|
||||
- **Defaults** (used when the objective doesn't specify a layout):
|
||||
- H1 stays the note title.
|
||||
- First, a 1-3 sentence rolling summary capturing the current state.
|
||||
- Then content organized by sub-topic under H2 headings, freshest/most-important first.
|
||||
- Tightness over decoration.
|
||||
- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly.
|
||||
- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
|
||||
- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1.
|
||||
|
||||
---
|
||||
|
||||
## Default Note Policy
|
||||
|
||||
Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block.
|
||||
|
||||
**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start:
|
||||
|
||||
- File missing → mark processed and do nothing.
|
||||
- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body.
|
||||
- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again.
|
||||
|
||||
---
|
||||
|
||||
## Renderer UI
|
||||
|
||||
- **Toolbar pill** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon pill with a state-dependent label, top-right of the editor toolbar. `markdown-editor.tsx` derives the state via `useLiveNoteForPath(notePath)` and passes a `LivePillState` prop:
|
||||
- `passive` → muted "Make live" label.
|
||||
- `idle` → "Live · 5 m" using `formatRelativeTime(lastRunAt)`.
|
||||
- `running` → "Updating…" with `animate-pulse` and a soft `bg-primary/10` highlight.
|
||||
- `error` → "Live · failed 5 m" in amber, off `lastAttemptAt`.
|
||||
Click dispatches `rowboat:open-live-note-panel` with `{ filePath }`. The hook ticks once a minute so the relative-time label stays fresh while the user has the editor open.
|
||||
- **Panel** — `apps/renderer/src/components/live-note-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-live-note-panel`; on open, calls `live-note:get` and renders. All mutations go through IPC.
|
||||
- Constant top header: Radio icon, "Live note" title, note name subtitle, X close.
|
||||
- Empty state (passive): "Make this note live" CTA — hands off to Copilot via `rowboat:open-copilot-edit-live-note`.
|
||||
- Editor (live): status row (schedule summary + active toggle — pulses with `animate-pulse` and `bg-primary/10` while running, label flips to "Updating…"), persistent error banner showing `lastRunError` until the next successful run, objective textarea, triggers editor (cron field + windows list + eventMatchCriteria textarea, each independently add/remove), last-run details, Advanced (collapsed; model + provider), footer (Edit with Copilot · Save · Run now / Stop), danger-zone "Make passive". The footer's primary action toggles between Run-now (idle) and Stop (running, destructive variant) — Stop calls `live-note:stop`.
|
||||
- **Status hook** — `apps/renderer/src/hooks/use-live-note-agent-status.ts`. Subscribes to `live-note-agent:events` IPC and maintains a `Map<filePath, LiveNoteAgentState>`.
|
||||
- **Live-state hook** — `apps/renderer/src/hooks/use-live-note-for-path.ts`. Fetches `live-note:get` on mount, refetches when the agent run completes (so `lastRunAt` / `lastRunSummary` / `lastRunError` are fresh), refetches when the file changes externally, and ticks once a minute for relative-time labels. Used by the markdown editor (toolbar pill) and could be reused by anyone needing reactive live-note state for a single path.
|
||||
- **Edit-with-Copilot flow** — panel dispatches `rowboat:open-copilot-edit-live-note` (App.tsx listener handles it via `submitFromPalette`).
|
||||
- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` has `STRUCTURED_KEYS = new Set(['live'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `live:` block back from `preserveRaw` on save.
|
||||
|
||||
---
|
||||
|
||||
## Prompts Catalog
|
||||
|
||||
Every LLM-facing prompt in the feature, with file pointers. 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 live notes *might* be relevant to an incoming event. Liberal — prefers false positives; the live-note agent does Pass 2.
|
||||
- **File**: `packages/core/src/knowledge/live-note/routing.ts` (`ROUTING_SYSTEM_PROMPT`).
|
||||
- **Output**: structured `Pass1OutputSchema` — `{ filePaths: string[] }`.
|
||||
- **Invoked by**: `findCandidates()` per batch of 20 notes via `generateObject({ model, system, prompt, schema })`.
|
||||
|
||||
### 2. Routing user prompt template
|
||||
|
||||
- **Purpose**: formats the event and the current batch of live notes into the user message for Pass 1.
|
||||
- **File**: `packages/core/src/knowledge/live-note/routing.ts` (`buildRoutingPrompt`).
|
||||
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedLiveNote[]` (each: `filePath`, `objective`, `eventMatchCriteria`).
|
||||
- **Output**: plain text, two sections — `## Event` and `## Live notes`.
|
||||
|
||||
### 3. Live-note agent instructions
|
||||
|
||||
- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the default body structure, prescribes patch-style updates, points at the knowledge graph.
|
||||
- **File**: `packages/core/src/knowledge/live-note/agent.ts` (`LIVE_NOTE_AGENT_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**: `buildLiveNoteAgent()`, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
|
||||
|
||||
### 4. Live-note agent message (`buildMessage`)
|
||||
|
||||
- **Purpose**: the user message seeded into each agent run.
|
||||
- **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`).
|
||||
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (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) and to make patch-style edits.
|
||||
|
||||
Three branches by `trigger`:
|
||||
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` 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 listing the note's `eventMatchCriteria` and the event payload, with the directive to skip the edit if the event isn't truly relevant.
|
||||
|
||||
### 5. Live Note skill (Copilot-facing)
|
||||
|
||||
- **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills.
|
||||
- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant.
|
||||
- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` is interpolated into the "Canonical Schema" section. Edits to `LiveNoteSchema` propagate automatically.
|
||||
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('live-note')` fires.
|
||||
- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`.
|
||||
|
||||
### 6. Copilot trigger paragraph
|
||||
|
||||
- **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded.
|
||||
- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live Notes" paragraph).
|
||||
- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…").
|
||||
- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up.
|
||||
- **Anti-signals (do NOT make live)**: definitional questions, one-off lookups, manual document editing.
|
||||
- **Extend-not-fork rule**: explicit guidance — "if the note is already live, extend its existing objective in natural language; never create a second objective."
|
||||
|
||||
### 7. `run-live-note-agent` tool — `context` parameter description
|
||||
|
||||
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run.
|
||||
- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-live-note-agent` tool definition).
|
||||
- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), optional `context`.
|
||||
- **Output**: flows into `runLiveNoteAgent(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message.
|
||||
- **Key use case**: backfill a newly-made-live note so its body isn't empty on day 1.
|
||||
|
||||
### 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` (`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.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
All live-note logs use the `PrefixLogger` with the prefix `LiveNote:<Component>` so they're greppable as a group. Every component logs lifecycle events at one consistent level.
|
||||
|
||||
| Component | Prefix | What it logs |
|
||||
|---|---|---|
|
||||
| Scheduler | `LiveNote:Scheduler` | One tick summary per tick when work happened (`tick — scanned N md, K live, fired J, backoff M`). Per-note `<path> — firing (matched cron)` and `<path> — skip (matched window, backoff 4m remaining)`. Quiet when no live notes or none due. |
|
||||
| Agent (runner) | `LiveNote:Agent` | `<path> — start trigger=cron runId=…`, `<path> — done action=replace summary="…"` (truncated to 120 chars), `<path> — failed: <msg>`, `<path> — skip: already running`. |
|
||||
| Routing | `LiveNote:Routing` | `event:<id> — routing against N live notes`, `event:<id> — Pass1 → K candidates: a.md, b.md`, `event:<id> — Pass1 batch X failed: …`. |
|
||||
| Events | `LiveNote:Events` | `event:<id> — received source=gmail type=email.synced`, `event:<id> — dispatching to K candidates: …`, `event:<id> — processed ok=2 errors=0`. |
|
||||
| Fileops | (only logs failures) | Lock contention or write errors. Otherwise silent. |
|
||||
|
||||
Conventions:
|
||||
- Lower-case verbs (`firing`, `skip`, `done`, `failed`) so lines scan visually.
|
||||
- File path is always the second token where applicable.
|
||||
- Run summaries truncated to 120 chars with a single `…` so log lines stay under terminal-width.
|
||||
- Scheduler emits *one* tick summary per tick, not a row per note. Per-note rows only when something fires or hits a notable skip.
|
||||
|
||||
## File Map
|
||||
|
||||
| Purpose | File |
|
||||
|---|---|
|
||||
| Zod schemas (live note, triggers, events, Pass1) | `packages/shared/src/live-note.ts` |
|
||||
| IPC channel schemas | `packages/shared/src/ipc.ts` |
|
||||
| IPC handlers (main process) | `apps/main/src/ipc.ts` |
|
||||
| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` |
|
||||
| File operations (`fetchLiveNote`, `setLiveNote`, `patchLiveNote`, `deleteLiveNote`, `setLiveNoteActive`, `readNoteBody`, `listLiveNotes`) | `packages/core/src/knowledge/live-note/fileops.ts` |
|
||||
| Scheduler (cron / windows) | `packages/core/src/knowledge/live-note/scheduler.ts` |
|
||||
| Trigger due-check helper (`computeNextDue` / `dueTimedTrigger`) | `packages/core/src/knowledge/live-note/schedule-utils.ts` |
|
||||
| Event producer + consumer loop | `packages/core/src/knowledge/live-note/events.ts` |
|
||||
| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/live-note/routing.ts` |
|
||||
| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` |
|
||||
| Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` |
|
||||
| Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` |
|
||||
| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` |
|
||||
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
|
||||
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
|
||||
| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |
|
||||
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
|
||||
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
|
||||
| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` |
|
||||
| Editor toolbar (Radio button → panel) | `apps/renderer/src/components/editor-toolbar.tsx` |
|
||||
| Live Note panel (single-view editor) | `apps/renderer/src/components/live-note-sidebar.tsx` |
|
||||
| Status hook (`useLiveNoteAgentStatus`) | `apps/renderer/src/hooks/use-live-note-agent-status.ts` |
|
||||
| Renderer frontmatter helper (preserves `live:`) | `apps/renderer/src/lib/frontmatter.ts` |
|
||||
| App-level listeners (panel open + Copilot edit) | `apps/renderer/src/App.tsx` |
|
||||
| Live Notes view (sidebar nav target) | `apps/renderer/src/components/live-notes-view.tsx` |
|
||||
| CSS (panel styles, legacy filenames) | `apps/renderer/src/styles/live-note-panel.css`, `apps/renderer/src/styles/editor.css` |
|
||||
| Main process startup (schedulers & processors) | `apps/main/src/main.ts` |
|
||||
343
apps/x/TRACKS.md
343
apps/x/TRACKS.md
|
|
@ -1,343 +0,0 @@
|
|||
# Track Blocks
|
||||
|
||||
> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand.
|
||||
|
||||
A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary.
|
||||
|
||||
**Example** (a Chicago-time track refreshed hourly):
|
||||
|
||||
~~~markdown
|
||||
```track
|
||||
trackId: chicago-time
|
||||
instruction: Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
```
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
2:30 PM, Central Time
|
||||
<!--/track-target:chicago-time-->
|
||||
~~~
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Product Overview](#product-overview)
|
||||
2. [Architecture at a Glance](#architecture-at-a-glance)
|
||||
3. [Technical Flows](#technical-flows)
|
||||
4. [Schema Reference](#schema-reference)
|
||||
5. [Prompts Catalog](#prompts-catalog)
|
||||
6. [File Map](#file-map)
|
||||
7. [Known Follow-ups](#known-follow-ups)
|
||||
|
||||
---
|
||||
|
||||
## Product Overview
|
||||
|
||||
### Trigger types
|
||||
|
||||
A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track.
|
||||
|
||||
| Trigger | When it fires | How to express it |
|
||||
|---|---|---|
|
||||
| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset |
|
||||
| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` |
|
||||
| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` |
|
||||
| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` |
|
||||
| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` |
|
||||
|
||||
Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals.
|
||||
|
||||
### Creating a track
|
||||
|
||||
Three paths, all produce identical on-disk YAML:
|
||||
|
||||
1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension.
|
||||
2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`.
|
||||
3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name.
|
||||
|
||||
### Viewing and managing a track
|
||||
|
||||
The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running.
|
||||
|
||||
Clicking the chip opens the **track modal**, where everything happens:
|
||||
|
||||
- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`).
|
||||
- **Tabs** — *What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata).
|
||||
- **Advanced** — expandable raw-YAML editor for power users.
|
||||
- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region.
|
||||
- **Footer** — *Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately).
|
||||
|
||||
Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`.
|
||||
|
||||
### What Copilot can do
|
||||
|
||||
- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`).
|
||||
- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event.
|
||||
- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`.
|
||||
- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill.
|
||||
|
||||
### After a run
|
||||
|
||||
- The **target region** (between `<!--track-target:ID-->` markers) is rewritten by the track-run agent using the `update-track-content` tool.
|
||||
- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML.
|
||||
- The chip pulses while running, then displays the latest `lastRunAt`.
|
||||
- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook.
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
Editor chip (display-only) ──click──► TrackModal (React)
|
||||
│
|
||||
├──► IPC: track:get / update /
|
||||
│ replaceYaml / delete / run
|
||||
│
|
||||
Backend (main process)
|
||||
├─ Scheduler loop (15 s) ──┐
|
||||
├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent
|
||||
└─ Copilot tool run-track-block ──┘ │
|
||||
▼
|
||||
update-track-content tool
|
||||
│
|
||||
▼
|
||||
target region rewritten on disk
|
||||
```
|
||||
|
||||
**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields.
|
||||
|
||||
**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context.
|
||||
|
||||
---
|
||||
|
||||
## Technical Flows
|
||||
|
||||
### 4.1 Scheduling (cron / window / once)
|
||||
|
||||
- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
|
||||
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`.
|
||||
- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed.
|
||||
- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
|
||||
- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
|
||||
|
||||
### 4.2 Event pipeline
|
||||
|
||||
**Producers** — any data source that should feed tracks emits events:
|
||||
|
||||
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
|
||||
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`.
|
||||
|
||||
**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
|
||||
|
||||
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
|
||||
|
||||
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
|
||||
2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`.
|
||||
3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below).
|
||||
4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
|
||||
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/<id>.json`, unlink from `pending/`.
|
||||
|
||||
**Pass 1 routing** (`routing.ts:73+ findCandidates`):
|
||||
|
||||
- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
|
||||
- Filter to `active && instruction && eventMatchCriteria` tracks.
|
||||
- Batches of `BATCH_SIZE = 20`.
|
||||
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file.
|
||||
- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config.
|
||||
|
||||
**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region.
|
||||
|
||||
### 4.3 Run flow (`triggerTrackUpdate`)
|
||||
|
||||
Module: `packages/core/src/knowledge/track/runner.ts`.
|
||||
|
||||
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
|
||||
2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`.
|
||||
3. **Create agent run** — `createRun({ agentId: 'track-run' })`.
|
||||
4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set.
|
||||
5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
|
||||
6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive.
|
||||
7. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
|
||||
8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`.
|
||||
9. **Store `lastRunSummary`** via `updateTrackBlock`.
|
||||
10. **Emit `track_run_complete`** with `summary` or `error`.
|
||||
11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
|
||||
|
||||
Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`.
|
||||
|
||||
### 4.4 IPC surface
|
||||
|
||||
| Channel | Caller → handler | Purpose |
|
||||
|---|---|---|
|
||||
| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
|
||||
| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` |
|
||||
| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML |
|
||||
| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML |
|
||||
| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region |
|
||||
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook |
|
||||
|
||||
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`.
|
||||
|
||||
### 4.5 Renderer integration
|
||||
|
||||
- **Chip** — `apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save.
|
||||
- **Modal** — `apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called.
|
||||
- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state.
|
||||
- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file.
|
||||
|
||||
### 4.6 Copilot skill integration
|
||||
|
||||
- **Skill content** — `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called.
|
||||
- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync.
|
||||
- **Skill registration** — `packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array).
|
||||
- **Loading trigger** — `packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests.
|
||||
- **Builtin tools** — `packages/core/src/application/lib/builtin-tools.ts`:
|
||||
- `update-track-content` — low-level: rewrite the target region between `<!--track-target:ID-->` markers. Used mainly by the track-run agent.
|
||||
- `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`).
|
||||
|
||||
### 4.7 Concurrency & FIFO guarantees
|
||||
|
||||
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC.
|
||||
- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file.
|
||||
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too.
|
||||
- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point.
|
||||
|
||||
---
|
||||
|
||||
## Schema Reference
|
||||
|
||||
All canonical schemas live in `packages/shared/src/track-block.ts`:
|
||||
|
||||
- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
|
||||
- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`.
|
||||
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`.
|
||||
- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`.
|
||||
|
||||
Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Prompts Catalog
|
||||
|
||||
Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`).
|
||||
|
||||
### 1. Routing system prompt (Pass 1 classifier)
|
||||
|
||||
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them.
|
||||
- **File**: `packages/core/src/knowledge/track/routing.ts:22–37` (`ROUTING_SYSTEM_PROMPT`).
|
||||
- **Inputs**: none interpolated — constant system prompt.
|
||||
- **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`.
|
||||
- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`.
|
||||
|
||||
### 2. Routing user prompt template
|
||||
|
||||
- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt.
|
||||
- **File**: `packages/core/src/knowledge/track/routing.ts:51–66` (`buildRoutingPrompt`).
|
||||
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`).
|
||||
- **Output**: plain text, two sections — `## Event` and `## Track Blocks`.
|
||||
- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below).
|
||||
|
||||
### 3. Track-run agent instructions
|
||||
|
||||
- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path.
|
||||
- **File**: `packages/core/src/knowledge/track/run-agent.ts:6–50` (`TRACK_RUN_INSTRUCTIONS`).
|
||||
- **Inputs**: `${WorkDir}` template literal (substituted at module load).
|
||||
- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`.
|
||||
- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
|
||||
|
||||
### 4. Track-run agent message (`buildMessage`)
|
||||
|
||||
- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`.
|
||||
- **File**: `packages/core/src/knowledge/track/runner.ts:23–62`.
|
||||
- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`.
|
||||
- **Output**: free-form — the agent decides whether to call `update-track-content`.
|
||||
|
||||
Three branches:
|
||||
|
||||
- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills.
|
||||
- **`timed`** — same as `manual`. Called by the scheduler with no `context`.
|
||||
- **`event`** — adds a **Pass 2 decision block** (lines 45–56). Quoted verbatim:
|
||||
|
||||
> **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
|
||||
>
|
||||
> **Event match criteria for this track:** …
|
||||
>
|
||||
> **Event payload:** …
|
||||
>
|
||||
> **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track.
|
||||
|
||||
### 5. Tracks skill (Copilot-facing)
|
||||
|
||||
- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context.
|
||||
- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant.
|
||||
- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically.
|
||||
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires.
|
||||
- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`.
|
||||
- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template.
|
||||
|
||||
### 6. Copilot trigger paragraph
|
||||
|
||||
- **Purpose**: tells Copilot *when* to load the `tracks` skill.
|
||||
- **File**: `packages/core/src/application/assistant/instructions.ts:73`.
|
||||
- **Inputs**: none; static prose.
|
||||
- **Output**: part of the baseline Copilot system prompt.
|
||||
- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh.
|
||||
|
||||
### 7. `run-track-block` tool — `context` parameter description
|
||||
|
||||
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema.
|
||||
- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt).
|
||||
- **Inputs**: free-form string from Copilot.
|
||||
- **Output**: flows into `triggerTrackUpdate(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message.
|
||||
- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`.
|
||||
|
||||
### 8. Calendar sync digest (event payload template)
|
||||
|
||||
- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`.
|
||||
- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126.
|
||||
- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync.
|
||||
- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars.
|
||||
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Purpose | File |
|
||||
|---|---|
|
||||
| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` |
|
||||
| IPC channel schemas | `packages/shared/src/ipc.ts` |
|
||||
| IPC handlers (main process) | `apps/main/src/ipc.ts` |
|
||||
| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` |
|
||||
| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` |
|
||||
| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` |
|
||||
| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` |
|
||||
| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` |
|
||||
| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` |
|
||||
| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` |
|
||||
| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` |
|
||||
| Track state type | `packages/core/src/knowledge/track/types.ts` |
|
||||
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
|
||||
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
|
||||
| Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` |
|
||||
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
|
||||
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
|
||||
| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` |
|
||||
| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` |
|
||||
| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` |
|
||||
| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` |
|
||||
| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` |
|
||||
| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` |
|
||||
| Main process startup (schedulers & processors) | `apps/main/src/main.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Known Follow-ups
|
||||
|
||||
- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields.
|
||||
- **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save.
|
||||
- **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor).
|
||||
|
||||
- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow.
|
||||
|
|
@ -31,6 +31,11 @@ await esbuild.build({
|
|||
// Replace import.meta.url directly with our polyfill variable
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
// Inject PostHog credentials at build time. Reuse the renderer's
|
||||
// VITE_PUBLIC_* envs so packaging only needs one set of values.
|
||||
// Empty strings disable analytics gracefully.
|
||||
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
|
||||
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.)',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<SuggestedBrowserSkill[] | undefined> {
|
||||
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': {
|
||||
|
|
|
|||
|
|
@ -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<string, CachedSnapshot>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
165
apps/x/apps/main/src/deeplink.ts
Normal file
165
apps/x/apps/main/src/deeplink.ts
Normal file
|
|
@ -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=<state>) 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<void> {
|
||||
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<void> {
|
||||
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=<state>. 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<void> {
|
||||
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();
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
listProviders,
|
||||
} from './oauth-handler.js';
|
||||
import { watcher as watcherCore, workspace } from '@x/core';
|
||||
import { WorkDir } from '@x/core/dist/config/config.js';
|
||||
import { workspace as workspaceShared } from '@x/shared';
|
||||
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
||||
import * as runsCore from '@x/core/dist/runs/runs.js';
|
||||
|
|
@ -34,6 +35,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';
|
||||
|
|
@ -44,14 +47,28 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
import {
|
||||
fetchYaml,
|
||||
updateTrackBlock,
|
||||
replaceTrackBlockYaml,
|
||||
deleteTrackBlock,
|
||||
} from '@x/core/dist/knowledge/track/fileops.js';
|
||||
fetchLiveNote,
|
||||
setLiveNote,
|
||||
setLiveNoteActive,
|
||||
deleteLiveNote,
|
||||
listLiveNotes,
|
||||
} from '@x/core/dist/knowledge/live-note/fileops.js';
|
||||
import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js';
|
||||
import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js';
|
||||
import {
|
||||
fetchTask,
|
||||
patchTask,
|
||||
createTask,
|
||||
deleteTask,
|
||||
listTasks,
|
||||
readRunIds as readTaskRunIds,
|
||||
} from '@x/core/dist/background-tasks/fileops.js';
|
||||
import { browserIpcHandlers } from './browser/ipc.js';
|
||||
|
||||
/**
|
||||
|
|
@ -342,7 +359,7 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
|
||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
|
|
@ -371,14 +388,27 @@ export async function startServicesWatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
let tracksWatcher: (() => void) | null = null;
|
||||
export function startTracksWatcher(): void {
|
||||
if (tracksWatcher) return;
|
||||
tracksWatcher = trackBus.subscribe((event) => {
|
||||
let liveNoteAgentWatcher: (() => void) | null = null;
|
||||
export function startLiveNoteAgentWatcher(): void {
|
||||
if (liveNoteAgentWatcher) return;
|
||||
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('tracks:events', event);
|
||||
win.webContents.send('live-note-agent:events', event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let backgroundTaskAgentWatcher: (() => void) | null = null;
|
||||
export function startBackgroundTaskAgentWatcher(): void {
|
||||
if (backgroundTaskAgentWatcher) return;
|
||||
backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('bg-task-agent:events', event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -415,6 +445,15 @@ 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(),
|
||||
apiUrl: API_URL,
|
||||
};
|
||||
},
|
||||
'workspace:getRoot': async () => {
|
||||
return workspace.getRoot();
|
||||
},
|
||||
|
|
@ -445,6 +484,20 @@ export function setupIpcHandlers() {
|
|||
'workspace:remove': async (_event, args) => {
|
||||
return workspace.remove(args.path, args.opts);
|
||||
},
|
||||
'gmail:getImportant': async (_event, args) => {
|
||||
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
|
||||
},
|
||||
'gmail:getEverythingElse': async (_event, args) => {
|
||||
return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit });
|
||||
},
|
||||
'gmail:triggerSync': async () => {
|
||||
triggerGmailSync();
|
||||
return {};
|
||||
},
|
||||
'gmail:saveMessageHeight': async (_event, args) => {
|
||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
@ -479,6 +532,35 @@ export function setupIpcHandlers() {
|
|||
await runsCore.deleteRun(args.runId);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:downloadLog': async (event, args) => {
|
||||
const runFileName = `${args.runId}.jsonl`;
|
||||
if (path.basename(runFileName) !== runFileName) {
|
||||
return { success: false, error: 'Invalid run id' };
|
||||
}
|
||||
|
||||
const sourcePath = path.join(WorkDir, 'runs', runFileName);
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${runFileName}.log`,
|
||||
filters: [
|
||||
{ name: 'Chat Log', extensions: ['log'] },
|
||||
{ name: 'JSONL', extensions: ['jsonl'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(sourcePath, result.filePath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'models:list': async () => {
|
||||
if (await isSignedIn()) {
|
||||
return await listGatewayModels();
|
||||
|
|
@ -600,11 +682,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 () => {
|
||||
|
|
@ -645,6 +724,11 @@ export function setupIpcHandlers() {
|
|||
const error = await shell.openPath(filePath);
|
||||
return { error: error || undefined };
|
||||
},
|
||||
'shell:showItemInFolder': async (_event, args) => {
|
||||
const filePath = resolveShellPath(args.path);
|
||||
shell.showItemInFolder(filePath);
|
||||
return { success: true };
|
||||
},
|
||||
'shell:readFileBase64': async (_event, args) => {
|
||||
const filePath = resolveShellPath(args.path);
|
||||
const stat = await fs.stat(filePath);
|
||||
|
|
@ -665,6 +749,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);
|
||||
|
|
@ -780,48 +877,135 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
// Track handlers
|
||||
'track:run': async (_event, args) => {
|
||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
||||
// Live-note handlers
|
||||
'live-note:run': async (_event, args) => {
|
||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||
return {
|
||||
success: !result.error,
|
||||
runId: result.runId,
|
||||
action: result.action,
|
||||
summary: result.summary,
|
||||
contentAfter: result.contentAfter,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
'track:get': async (_event, args) => {
|
||||
'live-note:get': async (_event, args) => {
|
||||
try {
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track not found' };
|
||||
return { success: true, yaml };
|
||||
const live = await fetchLiveNote(args.filePath);
|
||||
return { success: true, live };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:update': async (_event, args) => {
|
||||
'live-note:set': async (_event, args) => {
|
||||
try {
|
||||
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after update' };
|
||||
return { success: true, yaml };
|
||||
await setLiveNote(args.filePath, args.live);
|
||||
const live = await fetchLiveNote(args.filePath);
|
||||
return { success: true, live };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:replaceYaml': async (_event, args) => {
|
||||
'live-note:setActive': async (_event, args) => {
|
||||
try {
|
||||
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
|
||||
return { success: true, yaml };
|
||||
await setLiveNoteActive(args.filePath, args.active);
|
||||
const live = await fetchLiveNote(args.filePath);
|
||||
return { success: true, live };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:delete': async (_event, args) => {
|
||||
'live-note:delete': async (_event, args) => {
|
||||
try {
|
||||
await deleteTrackBlock(args.filePath, args.trackId);
|
||||
await deleteLiveNote(args.filePath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'live-note:stop': async (_event, args) => {
|
||||
try {
|
||||
const live = await fetchLiveNote(args.filePath);
|
||||
if (!live?.lastRunId) {
|
||||
return { success: false, error: 'No active run for this note' };
|
||||
}
|
||||
await runsCore.stop(live.lastRunId, false);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'live-note:listNotes': async () => {
|
||||
const notes = await listLiveNotes();
|
||||
return { notes };
|
||||
},
|
||||
// Bg-task handlers
|
||||
'bg-task:run': async (_event, args) => {
|
||||
const result = await runBackgroundTask(args.slug, 'manual', args.context);
|
||||
return {
|
||||
success: !result.error,
|
||||
runId: result.runId,
|
||||
summary: result.summary,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
'bg-task:get': async (_event, args) => {
|
||||
try {
|
||||
const task = await fetchTask(args.slug);
|
||||
return { success: true, task };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'bg-task:patch': async (_event, args) => {
|
||||
try {
|
||||
const task = await patchTask(args.slug, args.partial);
|
||||
return { success: true, task };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'bg-task:create': async (_event, args) => {
|
||||
try {
|
||||
const { slug } = await createTask({
|
||||
name: args.name,
|
||||
instructions: args.instructions,
|
||||
...(args.triggers ? { triggers: args.triggers } : {}),
|
||||
...(args.model ? { model: args.model } : {}),
|
||||
...(args.provider ? { provider: args.provider } : {}),
|
||||
});
|
||||
return { success: true, slug };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'bg-task:delete': async (_event, args) => {
|
||||
try {
|
||||
await deleteTask(args.slug);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'bg-task:stop': async (_event, args) => {
|
||||
try {
|
||||
const task = await fetchTask(args.slug);
|
||||
if (!task?.lastRunId) {
|
||||
return { success: false, error: 'No active run for this task' };
|
||||
}
|
||||
await runsCore.stop(task.lastRunId, false);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'bg-task:list': async (_event, args) => {
|
||||
return listTasks(args);
|
||||
},
|
||||
'bg-task:listRunIds': async (_event, args) => {
|
||||
const runIds = await readTaskRunIds(args.slug, args.limit);
|
||||
return { runIds };
|
||||
},
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import {
|
|||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startServicesWatcher,
|
||||
startTracksWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
startBackgroundTaskAgentWatcher,
|
||||
startWorkspaceWatcher,
|
||||
stopRunsWatcher,
|
||||
stopServicesWatcher,
|
||||
|
|
@ -23,19 +24,33 @@ 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 initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
||||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
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);
|
||||
|
||||
|
|
@ -45,6 +60,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.isPackaged && !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.).
|
||||
|
|
@ -65,7 +118,9 @@ function initializeExecutionEnvironment(): void {
|
|||
).trim();
|
||||
|
||||
const env = JSON.parse(stdout) as Record<string, string>;
|
||||
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);
|
||||
}
|
||||
|
|
@ -83,16 +138,29 @@ const rendererPath = app.isPackaged
|
|||
: path.join(__dirname, "../../../renderer/dist"); // Development
|
||||
console.log("rendererPath", rendererPath);
|
||||
|
||||
// Register custom protocol for serving built renderer files in production.
|
||||
// This keeps SPA routes working when users deep link into the packaged app.
|
||||
// Register custom protocol for serving built renderer files in production
|
||||
// AND for serving local workspace files to the renderer (images, PDFs, video).
|
||||
//
|
||||
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
|
||||
// app://<anything-else>/... → renderer SPA (existing behavior)
|
||||
function registerAppProtocol() {
|
||||
protocol.handle("app", (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// url.pathname starts with "/"
|
||||
let urlPath = url.pathname;
|
||||
// Workspace files: app://workspace/<rel-path>
|
||||
if (url.host === "workspace") {
|
||||
try {
|
||||
const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||
if (!relPath) return new Response("Not Found", { status: 404 });
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
return net.fetch(pathToFileURL(absPath).toString());
|
||||
} catch {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// If it's "/" or a SPA route (no extension), serve index.html
|
||||
// Renderer SPA — existing logic
|
||||
let urlPath = url.pathname;
|
||||
if (urlPath === "/" || !path.extname(urlPath)) {
|
||||
urlPath = "/index.html";
|
||||
}
|
||||
|
|
@ -111,8 +179,8 @@ protocol.registerSchemesAsPrivileged([
|
|||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
allowServiceWorkers: true,
|
||||
// optional but often helpful:
|
||||
// stream: true,
|
||||
// Required for byte-range requests so <video> seeking works.
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -157,12 +225,18 @@ function createWindow() {
|
|||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: preloadPath,
|
||||
// Enable Chromium's built-in PDFium plugin so <iframe src="*.pdf">
|
||||
// renders PDFs natively (zoom/scroll/print toolbar included).
|
||||
plugins: true,
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
|
|
@ -198,10 +272,10 @@ function createWindow() {
|
|||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register custom protocol before creating window (for production builds)
|
||||
if (app.isPackaged) {
|
||||
registerAppProtocol();
|
||||
}
|
||||
// Register custom protocol before creating window.
|
||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||
// serves workspace files via app://workspace/<rel-path> for media previews.
|
||||
registerAppProtocol();
|
||||
|
||||
// Initialize auto-updater (only in production)
|
||||
if (app.isPackaged) {
|
||||
|
|
@ -230,7 +304,15 @@ app.whenReady().then(async () => {
|
|||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
||||
// PostHog identify() is idempotent — call it on every startup so existing
|
||||
// signed-in installs (and every cold start of v0.3.4+) get re-identified.
|
||||
// Otherwise main-process events stay anonymous until the user re-signs-in.
|
||||
identifyIfSignedIn().catch((error) => {
|
||||
console.error('[Analytics] Failed to identify on startup:', error);
|
||||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
|
@ -250,14 +332,24 @@ app.whenReady().then(async () => {
|
|||
// start services watcher
|
||||
startServicesWatcher();
|
||||
|
||||
// start tracks watcher
|
||||
startTracksWatcher();
|
||||
// start live-note agent event watcher (forwards bus → renderer)
|
||||
startLiveNoteAgentWatcher();
|
||||
|
||||
// start track scheduler (cron/window/once)
|
||||
initTrackScheduler();
|
||||
// start bg-task agent event watcher (forwards bus → renderer)
|
||||
startBackgroundTaskAgentWatcher();
|
||||
|
||||
// start track event processor (consumes events/pending/, triggers matching tracks)
|
||||
initTrackEventProcessor();
|
||||
// start live-note scheduler (cron / window)
|
||||
initLiveNoteScheduler();
|
||||
|
||||
// start bg-task scheduler (cron / window)
|
||||
initBackgroundTaskScheduler();
|
||||
|
||||
// register event consumers and start the shared event processor
|
||||
// (consumes $WorkDir/events/pending/, routes events to all consumers
|
||||
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
|
||||
registerConsumer(liveNoteEventConsumer);
|
||||
registerConsumer(backgroundTaskEventConsumer);
|
||||
initEventProcessor();
|
||||
|
||||
// start gmail sync
|
||||
initGmailSync();
|
||||
|
|
@ -289,6 +381,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();
|
||||
|
||||
|
|
@ -318,4 +413,7 @@ app.on("before-quit", () => {
|
|||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
shutdownAnalytics().catch((error) => {
|
||||
console.error('[Analytics] Failed to flush on quit:', error);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Notification>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_
|
|||
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
||||
import { emitOAuthEvent } from './ipc.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
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';
|
||||
|
||||
|
|
@ -200,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.' };
|
||||
}
|
||||
}
|
||||
|
|
@ -256,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,
|
||||
});
|
||||
|
||||
|
|
@ -275,16 +300,33 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
// For Rowboat sign-in, ensure user + Stripe customer exist before
|
||||
// notifying the renderer. Without this, parallel API calls from
|
||||
// multiple renderer hooks race to create the user, causing duplicates.
|
||||
let signedInUserId: string | undefined;
|
||||
if (provider === 'rowboat') {
|
||||
try {
|
||||
await getBillingInfo();
|
||||
const billing = await getBillingInfo();
|
||||
if (billing.userId) {
|
||||
signedInUserId = billing.userId;
|
||||
analyticsIdentify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
analyticsCapture('user_signed_in', {
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
}
|
||||
} catch (meError) {
|
||||
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit success event to renderer
|
||||
emitOAuthEvent({ provider, success: true });
|
||||
emitOAuthEvent({
|
||||
provider,
|
||||
success: true,
|
||||
...(signedInUserId ? { userId: signedInUserId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OAuth token exchange failed:', error);
|
||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
||||
|
|
@ -340,13 +382,70 @@ 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=<state> URL.
|
||||
*/
|
||||
export async function completeRowboatGoogleConnect(state: string): Promise<void> {
|
||||
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');
|
||||
analyticsReset();
|
||||
}
|
||||
// Notify renderer so sidebar, voice, and billing re-check state
|
||||
emitOAuthEvent({ provider, success: false });
|
||||
return { success: true };
|
||||
|
|
|
|||
41
apps/x/apps/renderer/DESIGN_LANGUAGE.md
Normal file
41
apps/x/apps/renderer/DESIGN_LANGUAGE.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Rowboat Design Language
|
||||
|
||||
Rowboat should feel like a command center for people who live in notes, agents, email, meetings, and files all day. The launch direction is quiet, fast, and prosumer: dense enough for repeated work, warm enough to feel personal, and explicit about what the AI is doing.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Calm density**
|
||||
Keep the interface compact and scannable. Use tighter rows, restrained borders, and low-contrast panels so users can keep many contexts open without the app feeling heavy.
|
||||
|
||||
2. **Command first**
|
||||
Primary actions should feel like instant commands, not marketing CTAs. Side navigation, search, model selection, and composer controls use compact icon-led affordances with clear hover and selected states.
|
||||
|
||||
3. **Visible work state**
|
||||
AI actions, sync, saving, meeting capture, and background tasks need clear status surfaces. Prefer small persistent indicators over large banners.
|
||||
|
||||
4. **Notes as the canvas**
|
||||
The editor and conversation stay visually dominant. Chrome is supportive, not decorative. Avoid nested cards and oversized empty states in work surfaces.
|
||||
|
||||
5. **Neutral precision**
|
||||
The palette follows the dev color system: white and graphite surfaces, black/white primary actions, neutral command tools, and reserved semantic colors for destructive and chart states.
|
||||
|
||||
## Tokens
|
||||
|
||||
- Radius: `8px` for controls and cards, smaller where density matters.
|
||||
- Backgrounds: dev defaults in light and dark mode.
|
||||
- Borders: one-step darker than surfaces, quiet enough to separate panels without tinting them.
|
||||
- Shadows: reserved for the composer, menus, dialogs, and active segmented controls.
|
||||
- Type: system sans with tabular-feeling OpenType features enabled; no negative tracking.
|
||||
- Accent use: primary and command affordances use the neutral dev palette. Extra hues are reserved for semantic states and charts.
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
- **Sidebar:** persistent workflow switcher with calm selected states. Quick-action icons use neutral ink from the dev palette.
|
||||
- **Titlebar/tabs:** slim, scan-first navigation. Active tabs get a bottom signal line, not a bulky filled pill.
|
||||
- **Composer:** the highest-emphasis control outside the active canvas. It is slightly raised, flat, bordered by the primary tone, and sharp enough to feel like an input terminal.
|
||||
- **Messages:** user messages are compact structured blocks; assistant messages remain full-width and readable.
|
||||
- **Status:** sync, saving, recording, and task activity stay small but always visible near the surface they affect.
|
||||
|
||||
## Launch Positioning
|
||||
|
||||
The visual story is: **Rowboat is the personal AI workspace for people whose work already spans meetings, mail, notes, browser tasks, and agents.** It should feel closer to a focused desktop tool than a chat website.
|
||||
|
|
@ -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",
|
||||
|
|
@ -48,7 +49,9 @@
|
|||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-tweet": "^3.2.2",
|
||||
"recharts": "^3.8.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.6.10",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,9 @@ import {
|
|||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -224,3 +227,89 @@ export const ToolTabbedContent = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolGroupProps = {
|
||||
group: ToolGroupType
|
||||
isToolOpen: (toolId: string) => boolean
|
||||
onToolOpenChange: (toolId: string, open: boolean) => void
|
||||
}
|
||||
|
||||
const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => {
|
||||
if (tools.some(t => t.status === 'error')) return 'output-error'
|
||||
if (tools.some(t => t.status === 'running')) return 'input-available'
|
||||
if (tools.some(t => t.status === 'pending')) return 'input-streaming'
|
||||
return 'output-available'
|
||||
}
|
||||
|
||||
export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const state = getGroupState(group.items)
|
||||
const isCompleted = state === 'output-available' || state === 'output-error'
|
||||
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
||||
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
||||
const summary = isCompleted
|
||||
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
|
||||
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose mb-4 w-full rounded-md border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={summary}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
||||
title={summary}
|
||||
>
|
||||
{summary}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{group.items.map((tool) => {
|
||||
const toolState = toToolState(tool.status)
|
||||
const isOpen = isToolOpen(tool.id)
|
||||
return (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
||||
className="mb-0 border-border/60"
|
||||
>
|
||||
<ToolHeader
|
||||
title={getToolDisplayName(tool)}
|
||||
type={`tool-${tool.name}`}
|
||||
state={toolState}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
input={tool.input as ToolUIPart["input"]}
|
||||
output={tool.result as ToolUIPart["output"]}
|
||||
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
60
apps/x/apps/renderer/src/components/audio-file-viewer.tsx
Normal file
60
apps/x/apps/renderer/src/components/audio-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileAudioIcon } from 'lucide-react'
|
||||
|
||||
interface AudioFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'ready' | 'error'
|
||||
|
||||
function basename(path: string): string {
|
||||
const idx = path.lastIndexOf('/')
|
||||
return idx >= 0 ? path.slice(idx + 1) : path
|
||||
}
|
||||
|
||||
export function AudioFileViewer({ path }: AudioFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileAudioIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot play this audio file</p>
|
||||
<p className="max-w-md text-xs">The codec or container format isn't supported.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/30 px-6">
|
||||
<FileAudioIcon className="size-10 text-muted-foreground" />
|
||||
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
|
||||
{basename(path)}
|
||||
</p>
|
||||
<audio
|
||||
key={path}
|
||||
src={src}
|
||||
controls
|
||||
className="w-full max-w-lg"
|
||||
onLoadedMetadata={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, FolderOpen, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
||||
|
|
@ -103,9 +103,18 @@ type BasesViewProps = {
|
|||
rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
}
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('mac')) return 'Finder'
|
||||
if (platform.includes('win')) return 'Explorer'
|
||||
return 'File Manager'
|
||||
}
|
||||
|
||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
return nodes.flatMap((n) =>
|
||||
n.kind === 'file' && n.name.endsWith('.md')
|
||||
|
|
@ -919,6 +928,10 @@ function NoteRow({
|
|||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions?.revealInFileManager(note.path, false)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {getFileManagerName()}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
|
|
|
|||
1688
apps/x/apps/renderer/src/components/bg-tasks-view.tsx
Normal file
1688
apps/x/apps/renderer/src/components/bg-tasks-view.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<BrowserState>(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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -69,13 +73,20 @@ const providerDisplayNames: Record<string, string> = {
|
|||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
|
||||
interface ConfiguredModel {
|
||||
flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
provider: ProviderName
|
||||
model: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
headers?: Record<string, string>
|
||||
knowledgeGraphModel?: string
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
function getSelectedModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
|
|
@ -120,6 +131,8 @@ interface ChatInputInnerProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
|
|
@ -145,6 +158,7 @@ function ChatInputInner({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -155,9 +169,27 @@ function ChatInputInner({
|
|||
|
||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [workDir, setWorkDir] = useState<string | null>(null)
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setLockedModel(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
||||
if (cancelled) return
|
||||
if (run.provider && run.model) {
|
||||
setLockedModel({ provider: run.provider, model: run.model })
|
||||
}
|
||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
useEffect(() => {
|
||||
|
|
@ -176,42 +208,20 @@ function ChatInputInner({
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Load model config (gateway when signed in, local config when BYOK)
|
||||
// Load the list of models the user can choose from.
|
||||
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
try {
|
||||
if (isRowboatConnected) {
|
||||
// Fetch gateway models
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = listResult.providers?.find(
|
||||
(p: { id: string }) => p.id === 'rowboat'
|
||||
)
|
||||
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
||||
(m: { id: string }) => ({ flavor: 'rowboat', model: m.id })
|
||||
(m: { id: string }) => ({ provider: 'rowboat', model: m.id })
|
||||
)
|
||||
|
||||
// Read current default from config
|
||||
let defaultModel = ''
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
defaultModel = parsed?.model || ''
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
if (defaultModel) {
|
||||
models.sort((a, b) => {
|
||||
if (a.model === defaultModel) return -1
|
||||
if (b.model === defaultModel) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
setConfiguredModels(models)
|
||||
const activeKey = defaultModel
|
||||
? `rowboat/${defaultModel}`
|
||||
: models[0] ? `rowboat/${models[0].model}` : ''
|
||||
if (activeKey) setActiveModelKey(activeKey)
|
||||
} else {
|
||||
// BYOK: read from local models.json
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ConfiguredModel[] = []
|
||||
|
|
@ -223,32 +233,12 @@ function ChatInputInner({
|
|||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({
|
||||
flavor: flavor as ConfiguredModel['flavor'],
|
||||
model,
|
||||
apiKey: (e.apiKey as string) || undefined,
|
||||
baseURL: (e.baseURL as string) || undefined,
|
||||
headers: (e.headers as Record<string, string>) || undefined,
|
||||
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||
})
|
||||
models.push({ provider: flavor as ProviderName, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||
? `${parsed.provider.flavor}/${parsed.model}`
|
||||
: ''
|
||||
models.sort((a, b) => {
|
||||
const aKey = `${a.flavor}/${a.model}`
|
||||
const bKey = `${b.flavor}/${b.model}`
|
||||
if (aKey === defaultKey) return -1
|
||||
if (bKey === defaultKey) return 1
|
||||
return 0
|
||||
})
|
||||
setConfiguredModels(models)
|
||||
if (defaultKey) {
|
||||
setActiveModelKey(defaultKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No config yet
|
||||
|
|
@ -266,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 () => {
|
||||
|
|
@ -284,40 +323,15 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
const handleModelChange = useCallback(async (key: string) => {
|
||||
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
|
||||
// Selecting a model affects only the *next* run created from this tab.
|
||||
// Once a run exists, model is frozen on the run and the dropdown is read-only.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
if (lockedModel) return
|
||||
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
|
||||
if (!entry) return
|
||||
setActiveModelKey(key)
|
||||
|
||||
try {
|
||||
if (entry.flavor === 'rowboat') {
|
||||
// Gateway model — save with valid Zod flavor, no credentials
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: { flavor: 'openrouter' as const },
|
||||
model: entry.model,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
} else {
|
||||
// BYOK — preserve full provider config
|
||||
const providerModels = configuredModels
|
||||
.filter((m) => m.flavor === entry.flavor)
|
||||
.map((m) => m.model)
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: {
|
||||
flavor: entry.flavor,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
},
|
||||
model: entry.model,
|
||||
models: providerModels,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to switch model')
|
||||
}
|
||||
}, [configuredModels])
|
||||
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
|
||||
}, [configuredModels, lockedModel, onSelectedModelChange])
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
|
|
@ -420,7 +434,7 @@ function ChatInputInner({
|
|||
}, [addFiles, isActive])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
|
|
@ -524,14 +538,53 @@ function ChatInputInner({
|
|||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Attach files"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
|
||||
<ImagePlus className="size-4" />
|
||||
<span>Add files or photos</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
|
||||
<FolderCog className="size-4" />
|
||||
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
||||
</DropdownMenuItem>
|
||||
{workDir && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}>
|
||||
<X className="size-4" />
|
||||
<span>Clear work directory</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{workDir && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex h-7 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Work directory: {workDir}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{searchAvailable && (
|
||||
searchEnabled ? (
|
||||
<button
|
||||
|
|
@ -555,7 +608,14 @@ function ChatInputInner({
|
|||
)
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{configuredModels.length > 0 && (
|
||||
{lockedModel ? (
|
||||
<span
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
||||
>
|
||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
) : configuredModels.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -563,7 +623,7 @@ function ChatInputInner({
|
|||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
|
||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -571,18 +631,18 @@ function ChatInputInner({
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
||||
{configuredModels.map((m) => {
|
||||
const key = `${m.flavor}/${m.model}`
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
) : null}
|
||||
{onToggleTts && ttsAvailable && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip>
|
||||
|
|
@ -729,6 +789,7 @@ export interface ChatInputWithMentionsProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -757,6 +818,7 @@ export function ChatInputWithMentions({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -783,6 +845,7 @@ export function ChatInputWithMentions({
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
|
||||
import { Bug, Maximize2, Minimize2, MoreHorizontal, SquarePen } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
|
|
@ -16,18 +23,22 @@ import {
|
|||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
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 { 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 { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
|
|
@ -38,9 +49,11 @@ import {
|
|||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
|
|
@ -49,6 +62,36 @@ import {
|
|||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
// Render user messages with markdown so bullets, bold, links, etc. survive the
|
||||
// round-trip from the input textarea. `remarkBreaks` turns single newlines
|
||||
// into <br> so typed line breaks are preserved without requiring blank lines.
|
||||
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
||||
|
||||
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
const ref = useRef<HTMLPreElement>(null)
|
||||
const stickToBottom = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<pre ref={ref} onScroll={handleScroll} className={className}>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Billing error helpers ─── */
|
||||
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
|
|
@ -61,7 +104,7 @@ const BILLING_ERROR_PATTERNS = [
|
|||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: 'You\'ve run out of credits',
|
||||
subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.',
|
||||
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
|
|
@ -158,6 +201,7 @@ interface ChatSidebarProps {
|
|||
onPresetMessageConsumed?: () => void
|
||||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
|
|
@ -167,6 +211,7 @@ interface ChatSidebarProps {
|
|||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||
onOpenKnowledgeFile?: (path: string) => void
|
||||
onActivate?: () => void
|
||||
collapsedLeftPaddingPx?: number
|
||||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
|
|
@ -211,6 +256,7 @@ export function ChatSidebar({
|
|||
onPresetMessageConsumed,
|
||||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
|
|
@ -220,6 +266,7 @@ export function ChatSidebar({
|
|||
onToolOpenChangeForTab,
|
||||
onOpenKnowledgeFile,
|
||||
onActivate,
|
||||
collapsedLeftPaddingPx = 196,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
|
|
@ -234,6 +281,7 @@ export function ChatSidebar({
|
|||
onTtsModeChange,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [showContent, setShowContent] = useState(isOpen)
|
||||
|
|
@ -340,6 +388,25 @@ export function ChatSidebar({
|
|||
return chatTabStates[tabId] ?? emptyTabState
|
||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
||||
const activeRunId = activeTabState.runId
|
||||
const handleDownloadChatLog = useCallback(async () => {
|
||||
if (!activeRunId) {
|
||||
toast.error('No chat log available yet')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
if (result.success) {
|
||||
toast.success('Chat log saved')
|
||||
} else if (result.error) {
|
||||
toast.error(result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Download chat log failed:', err)
|
||||
toast.error('Failed to download chat log')
|
||||
}
|
||||
}, [activeRunId])
|
||||
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
|
|
@ -351,7 +418,14 @@ export function ChatSidebar({
|
|||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -372,7 +446,12 @@ export function ChatSidebar({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{message}
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -425,7 +504,13 @@ export function ChatSidebar({
|
|||
>
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
{item.streamingOutput ? (
|
||||
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||
<TerminalOutput raw={item.streamingOutput} />
|
||||
</AutoScrollPre>
|
||||
) : (
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -496,7 +581,14 @@ export function ChatSidebar({
|
|||
|
||||
{showContent && (
|
||||
<>
|
||||
<header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||
<header
|
||||
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar"
|
||||
style={{
|
||||
paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined,
|
||||
paddingRight: isMaximized ? 12 : undefined,
|
||||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
<TabBar
|
||||
tabs={chatTabs}
|
||||
activeTabId={activeChatTabId}
|
||||
|
|
@ -519,6 +611,34 @@ export function ChatSidebar({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Chat options"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Chat options</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={!activeRunId}
|
||||
onSelect={() => {
|
||||
void handleDownloadChatLog()
|
||||
}}
|
||||
>
|
||||
<Bug className="size-4" />
|
||||
Download chat log
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onOpenFullScreen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -570,7 +690,20 @@ export function ChatSidebar({
|
|||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map((item) => {
|
||||
{groupConversationItems(
|
||||
tabState.conversation,
|
||||
(id) => !!tabState.allPermissionRequests.get(id)
|
||||
).map((item) => {
|
||||
if (isToolGroup(item)) {
|
||||
return (
|
||||
<ToolGroupComponent
|
||||
key={item.groupId}
|
||||
group={item}
|
||||
isToolOpen={(toolId) => isToolOpenForTab?.(tab.id, toolId) ?? false}
|
||||
onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
|
|
@ -662,6 +795,7 @@ export function ChatSidebar({
|
|||
runId={tabState.runId}
|
||||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
|
|
|
|||
78
apps/x/apps/renderer/src/components/compact-conversation.tsx
Normal file
78
apps/x/apps/renderer/src/components/compact-conversation.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import {
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
getToolDisplayName,
|
||||
toToolState,
|
||||
normalizeToolOutput,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { Tool, ToolHeader, ToolContent, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
|
||||
/**
|
||||
* Compact rendering of a run's conversation log — used by the live-note panel's
|
||||
* "Last run" tab and the bg-task sidebar's "Runs history" drill-down. Keep this
|
||||
* the single source of truth so the two surfaces stay visually aligned.
|
||||
*
|
||||
* - User messages: right-aligned secondary bubble, plain text.
|
||||
* - Assistant messages: full-width markdown.
|
||||
* - Tool calls: collapsible `Tool` row with tabbed input/output.
|
||||
* - Errors: destructive-tinted banner.
|
||||
*/
|
||||
export function CompactConversation({ items }: { items: ConversationItem[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{items.map((item) => {
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<div key={item.id} className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{item.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isToolCall(item)) return <CompactToolRow key={item.id} tool={item} />
|
||||
if (isChatMessage(item)) {
|
||||
const isUser = item.role === 'user'
|
||||
return (
|
||||
<div key={item.id} className={isUser ? 'flex justify-end' : ''}>
|
||||
<div className={isUser
|
||||
? 'max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-xs text-foreground whitespace-pre-wrap break-words'
|
||||
: 'w-full text-xs text-foreground'}>
|
||||
{isUser ? (
|
||||
item.content
|
||||
) : (
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ol]:my-1.5 [&_pre]:my-2 [&_pre]:text-[11px] [&_code]:text-[11px]">
|
||||
{item.content}
|
||||
</Streamdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactToolRow({ tool }: { tool: ToolCall }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const title = getToolDisplayName(tool)
|
||||
const state = toToolState(tool.status)
|
||||
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
|
||||
return (
|
||||
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
|
||||
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
input={tool.input}
|
||||
output={normalizeToolOutput(tool.result, tool.status) ?? undefined}
|
||||
errorText={errorText}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ComposioGoogleMigrationModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReconnect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time modal shown to signed-in users who had Gmail/Calendar connected
|
||||
* via Composio before the native rowboat-mode OAuth flow shipped. By the
|
||||
* time this opens, the Composio Google accounts have already been
|
||||
* disconnected (fire-and-forget, on the qualification IPC) — the modal
|
||||
* just explains what happened and offers a one-click reconnect.
|
||||
*
|
||||
* Both buttons close the modal. The qualification IPC marks the migration
|
||||
* as dismissed before showing this, so neither button needs a follow-up
|
||||
* IPC of its own.
|
||||
*/
|
||||
export function ComposioGoogleMigrationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onReconnect,
|
||||
}: ComposioGoogleMigrationModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
|
||||
<div className="p-6 pb-0">
|
||||
<DialogHeader className="space-y-1.5">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
Reconnect Google to resume syncing
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm leading-relaxed">
|
||||
<p>
|
||||
Knowledge graph syncing for Gmail and Calendar now uses a
|
||||
direct Google connection. Reconnect to resume. Your existing
|
||||
emails and events stay where they are.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 py-4 mt-6 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onReconnect()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Reconnect Google
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,16 +79,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
c.setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
c.setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
c.startConnect(provider)
|
||||
}}
|
||||
onClick={() => c.handleReconnect(provider)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Reconnect
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -42,6 +43,21 @@ interface EditorToolbarProps {
|
|||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
onOpenLiveNote?: () => void
|
||||
liveState?: LivePillState
|
||||
}
|
||||
|
||||
export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error'
|
||||
export interface LivePillState {
|
||||
variant: LivePillVariant
|
||||
label: string
|
||||
}
|
||||
|
||||
const LIVE_PILL_VARIANT_CLASS: Record<LivePillVariant, string> = {
|
||||
passive: 'text-muted-foreground hover:bg-accent',
|
||||
idle: 'text-foreground hover:bg-accent',
|
||||
running: 'text-foreground bg-primary/10 hover:bg-primary/15 animate-pulse',
|
||||
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
|
|
@ -49,6 +65,8 @@ export function EditorToolbar({
|
|||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onExport,
|
||||
onOpenLiveNote,
|
||||
liveState,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -385,6 +403,19 @@ export function EditorToolbar({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live Note pill — pushed to far right */}
|
||||
{onOpenLiveNote && liveState && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLiveNote}
|
||||
title={liveState.variant === 'passive' ? 'Make this note live' : 'Live note'}
|
||||
className={`ml-auto inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors ${LIVE_PILL_VARIANT_CLASS[liveState.variant]}`}
|
||||
>
|
||||
<Radio className="size-3.5" />
|
||||
<span className="truncate max-w-[160px]">{liveState.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
1226
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
1226
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, string | string[]> = {}
|
||||
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 `live:`) 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) => {
|
||||
|
|
|
|||
155
apps/x/apps/renderer/src/components/html-file-viewer.tsx
Normal file
155
apps/x/apps/renderer/src/components/html-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
const MAX_SIZE_BYTES = 5 * 1024 * 1024
|
||||
const CACHE_MAX_ENTRIES = 20
|
||||
|
||||
type CacheEntry = { html: string; mtimeMs: number; size: number }
|
||||
const htmlCache = new Map<string, CacheEntry>()
|
||||
|
||||
function getCached(path: string, mtimeMs: number, size: number): string | null {
|
||||
const entry = htmlCache.get(path)
|
||||
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
|
||||
// Refresh LRU position
|
||||
htmlCache.delete(path)
|
||||
htmlCache.set(path, entry)
|
||||
return entry.html
|
||||
}
|
||||
|
||||
function setCached(path: string, html: string, mtimeMs: number, size: number) {
|
||||
htmlCache.set(path, { html, mtimeMs, size })
|
||||
while (htmlCache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldest = htmlCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
htmlCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
type ViewerState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded'; html: string }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'tooLarge'; sizeMB: number }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
interface HtmlFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
||||
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ kind: 'loading' })
|
||||
setIframeLoaded(false)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const stat = await window.ipc.invoke('workspace:stat', { path })
|
||||
if (cancelled) return
|
||||
if (stat.kind !== 'file') {
|
||||
setState({ kind: 'error', message: 'Selected path is not a file.' })
|
||||
return
|
||||
}
|
||||
if (stat.size > MAX_SIZE_BYTES) {
|
||||
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
||||
return
|
||||
}
|
||||
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
|
||||
if (cachedHtml !== null) {
|
||||
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
|
||||
return
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
if (cancelled) return
|
||||
setCached(path, result.data, stat.mtimeMs, stat.size)
|
||||
if (!result.data || result.data.trim() === '') {
|
||||
setState({ kind: 'empty' })
|
||||
return
|
||||
}
|
||||
setState({ kind: 'loaded', html: result.data })
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setState({ kind: 'error', message })
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [path])
|
||||
|
||||
if (state.kind === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground">
|
||||
<AlertCircleIcon className="size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-foreground">Could not load preview</p>
|
||||
<p className="max-w-md text-xs">{state.message}</p>
|
||||
<p className="text-xs opacity-60">{path}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'empty') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<FileTextIcon className="size-6" />
|
||||
<p className="text-sm">This file is empty</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'tooLarge') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileTextIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">File too large to preview</p>
|
||||
<p className="text-xs">
|
||||
{state.sizeMB.toFixed(1)} MB — preview limit is {(MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
|
||||
// gets a null origin with no base URL. Trade-off: relative assets inside
|
||||
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
|
||||
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
|
||||
// works fine; HTML that ships next to sibling assets will look broken.
|
||||
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
|
||||
// support; that path also resolves through the existing path-traversal
|
||||
// guard in resolveWorkspacePath.
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{state.kind === 'loaded' && (
|
||||
<iframe
|
||||
key={path}
|
||||
srcDoc={state.html}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{(state.kind === 'loading' || !iframeLoaded) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Rendering preview…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
apps/x/apps/renderer/src/components/image-file-viewer.tsx
Normal file
58
apps/x/apps/renderer/src/components/image-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileImageIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
interface ImageFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'loaded' | 'error'
|
||||
|
||||
export function ImageFileViewer({ path }: ImageFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileImageIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot preview this image</p>
|
||||
<p className="max-w-md text-xs">The format may be unsupported (e.g. HEIC on Windows).</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted/30">
|
||||
<img
|
||||
key={path}
|
||||
src={src}
|
||||
alt={path}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
onLoad={() => setState('loaded')}
|
||||
onError={() => setState('error')}
|
||||
style={state === 'loading' ? { opacity: 0 } : undefined}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading image…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
962
apps/x/apps/renderer/src/components/live-note-sidebar.tsx
Normal file
962
apps/x/apps/renderer/src/components/live-note-sidebar.tsx
Normal file
|
|
@ -0,0 +1,962 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import '@/styles/live-note-panel.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Play, Square, Loader2, Sparkles,
|
||||
AlertCircle, Plus, X, Check, Pencil, Radio, Repeat, Clock, Zap,
|
||||
ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import type z from 'zod'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
|
||||
export type OpenLiveNotePanelDetail = {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly, on the hour',
|
||||
'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',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
function summarizeSchedule(triggers: Triggers | undefined): string {
|
||||
if (!triggers) return 'Manual only'
|
||||
const parts: string[] = []
|
||||
if (triggers.cronExpr) parts.push(describeCron(triggers.cronExpr))
|
||||
if (triggers.windows && triggers.windows.length > 0) {
|
||||
parts.push(triggers.windows.length === 1
|
||||
? `${triggers.windows[0].startTime}–${triggers.windows[0].endTime}`
|
||||
: `${triggers.windows.length} windows`)
|
||||
}
|
||||
if (triggers.eventMatchCriteria) parts.push('events')
|
||||
return parts.length === 0 ? 'Manual only' : parts.join(' · ')
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
||||
function formatRunAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const date = d.toLocaleString('en-US', { month: 'short', day: 'numeric' })
|
||||
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
||||
return `${date} · ${time}`
|
||||
}
|
||||
|
||||
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/
|
||||
|
||||
type Tab = 'objective' | 'last-run' | 'details'
|
||||
|
||||
export interface LiveNoteSidebarProps {
|
||||
/**
|
||||
* Note path the panel should bind to. Workspace-relative (`knowledge/Foo.md`)
|
||||
* or full — both forms are accepted; the prefix is stripped internally.
|
||||
* `null` (or empty) hides the panel entirely.
|
||||
*/
|
||||
filePath: string | null
|
||||
/** Called when the user clicks the close button or hands off to Copilot. */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
||||
const [live, setLive] = useState<LiveNote | null>(null)
|
||||
const [draft, setDraft] = useState<LiveNote | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('objective')
|
||||
const [editingObjective, setEditingObjective] = useState(false)
|
||||
const [editingEvents, setEditingEvents] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath ?? ''), [filePath])
|
||||
const agentStatus = useLiveNoteAgentStatus()
|
||||
const runState = agentStatus.get(knowledgeRelPath) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
const refresh = useCallback(async (relPath: string) => {
|
||||
if (!relPath) { setLive(null); setDraft(null); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:get', { filePath: relPath })
|
||||
if (!res.success) {
|
||||
setError(res.error ?? 'Failed to load')
|
||||
setLive(null)
|
||||
setDraft(null)
|
||||
return
|
||||
}
|
||||
setLive(res.live ?? null)
|
||||
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
|
||||
setConfirmingDelete(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setLive(null)
|
||||
setDraft(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setTab('objective')
|
||||
setEditingObjective(false)
|
||||
setEditingEvents(false)
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
if (knowledgeRelPath) {
|
||||
void refresh(knowledgeRelPath)
|
||||
} else {
|
||||
setLive(null)
|
||||
setDraft(null)
|
||||
}
|
||||
}, [knowledgeRelPath, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!knowledgeRelPath) return
|
||||
const state = agentStatus.get(knowledgeRelPath)
|
||||
if (state && (state.status === 'done' || state.status === 'error')) {
|
||||
void refresh(knowledgeRelPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentStatus, knowledgeRelPath])
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!live || !draft) return false
|
||||
return JSON.stringify(live) !== JSON.stringify(draft)
|
||||
}, [live, draft])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!knowledgeRelPath || !draft) return
|
||||
const parsed = LiveNoteSchema.safeParse(draft)
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map(i => i.message).join('; '))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:set', { filePath: knowledgeRelPath, live: parsed.data })
|
||||
if (!res.success) {
|
||||
setError(res.error ?? 'Save failed')
|
||||
return
|
||||
}
|
||||
setLive(res.live ?? null)
|
||||
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
|
||||
setEditingObjective(false)
|
||||
setEditingEvents(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, draft])
|
||||
|
||||
const handleCancelObjective = useCallback(() => {
|
||||
if (live) setDraft(d => d ? { ...d, objective: live.objective } : d)
|
||||
setEditingObjective(false)
|
||||
}, [live])
|
||||
|
||||
const handleToggleActive = useCallback(async () => {
|
||||
if (!knowledgeRelPath || !live) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:setActive', {
|
||||
filePath: knowledgeRelPath,
|
||||
active: live.active === false,
|
||||
})
|
||||
if (!res.success) {
|
||||
setError(res.error ?? 'Failed')
|
||||
return
|
||||
}
|
||||
setLive(res.live ?? null)
|
||||
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, live])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!knowledgeRelPath) return
|
||||
setError(null)
|
||||
try {
|
||||
await window.ipc.invoke('live-note:run', { filePath: knowledgeRelPath })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [knowledgeRelPath])
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!knowledgeRelPath) return
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelPath })
|
||||
if (!res.success && res.error) setError(res.error)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [knowledgeRelPath])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!knowledgeRelPath) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:delete', { filePath: knowledgeRelPath })
|
||||
if (!res.success) {
|
||||
setError(res.error ?? 'Delete failed')
|
||||
return
|
||||
}
|
||||
setLive(null)
|
||||
setDraft(null)
|
||||
setConfirmingDelete(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!filePath) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-live-note', {
|
||||
detail: { filePath },
|
||||
}))
|
||||
onClose()
|
||||
}, [filePath, onClose])
|
||||
|
||||
if (!filePath) return null
|
||||
|
||||
const noteTitle = filePath
|
||||
? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '')
|
||||
: 'Live note'
|
||||
const paused = live?.active === false
|
||||
|
||||
// Empty state — passive note.
|
||||
if (!loading && !live) {
|
||||
return (
|
||||
<aside className="flex w-[440px] max-w-[40vw] shrink-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-4">
|
||||
<Radio className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-semibold">{noteTitle}</span>
|
||||
<span className="ml-auto" />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<Radio className="size-8 text-muted-foreground/40" />
|
||||
<div className="text-sm font-medium text-foreground">This note is passive</div>
|
||||
<div className="text-xs text-muted-foreground max-w-[260px]">
|
||||
Make it live to have an agent keep its body up to date — describe what you want it to track and how often.
|
||||
</div>
|
||||
<Button size="sm" onClick={handleEditWithCopilot} className="mt-2">
|
||||
<Sparkles className="size-3" />
|
||||
Make this note live
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex w-[440px] max-w-[40vw] shrink-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-4">
|
||||
<Radio
|
||||
className={`size-4 shrink-0 ${paused ? 'text-muted-foreground' : 'text-emerald-600 dark:text-emerald-400'}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold">{noteTitle}</span>
|
||||
<span className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
paused
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
}`}>
|
||||
<span className={`size-1.5 rounded-full ${paused ? 'bg-muted-foreground/60' : 'bg-emerald-500'} ${isRunning ? 'animate-pulse' : ''}`} aria-hidden />
|
||||
{paused ? 'Paused' : 'Live note'}
|
||||
</span>
|
||||
<span className="ml-auto" />
|
||||
<Switch
|
||||
checked={!paused}
|
||||
onCheckedChange={handleToggleActive}
|
||||
disabled={saving || !live}
|
||||
aria-label="Active"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && live && draft && (
|
||||
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-90' : ''}`}>
|
||||
{/* Status strip — 2 columns: Last run · Triggers. */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Last run</div>
|
||||
<div className="mt-0.5 truncate text-xs text-foreground">
|
||||
{live.lastRunAt
|
||||
? <>
|
||||
{formatRelativeTime(live.lastRunAt)} ago
|
||||
{live.lastRunError && <span className="text-destructive"> · error</span>}
|
||||
</>
|
||||
: <span className="text-muted-foreground">Never</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</div>
|
||||
<div className="mt-0.5 truncate text-xs text-foreground">{summarizeSchedule(live.triggers)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex shrink-0 border-b border-border px-4">
|
||||
<TabButton active={tab === 'objective'} onClick={() => setTab('objective')}>Objective</TabButton>
|
||||
<TabButton
|
||||
active={tab === 'last-run'}
|
||||
onClick={() => setTab('last-run')}
|
||||
disabled={!live.lastRunId}
|
||||
>
|
||||
Last run
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'details'} onClick={() => setTab('details')}>Details</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === 'objective' && (
|
||||
<ObjectiveTab
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
editing={editingObjective}
|
||||
onCancel={handleCancelObjective}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'last-run' && (
|
||||
<LastRunTab live={live} />
|
||||
)}
|
||||
|
||||
{tab === 'details' && (
|
||||
<DetailsTab
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
editingEvents={editingEvents}
|
||||
setEditingEvents={setEditingEvents}
|
||||
showAdvanced={showAdvanced}
|
||||
setShowAdvanced={setShowAdvanced}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
onDelete={handleDelete}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer — context-dependent. */}
|
||||
{tab === 'objective' && editingObjective ? (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/20 px-4 py-2.5">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelObjective} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !isDirty}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-4 py-2.5">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Running
|
||||
</span>
|
||||
<span className="ml-auto" />
|
||||
<Button variant="destructive" size="sm" onClick={handleStop} disabled={saving}>
|
||||
<Square className="size-3" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{tab === 'objective' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditingObjective(true)} disabled={saving}>
|
||||
<Pencil className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleEditWithCopilot} disabled={saving}>
|
||||
<Sparkles className="size-3" />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
{isDirty && tab === 'details' && (
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<span className="ml-auto" />
|
||||
<Button size="sm" onClick={handleRun} disabled={saving}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`relative px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'text-foreground after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:bg-foreground'
|
||||
: disabled
|
||||
? 'text-muted-foreground/50 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ObjectiveTab({
|
||||
draft,
|
||||
setDraft,
|
||||
editing,
|
||||
onCancel,
|
||||
}: {
|
||||
draft: LiveNote
|
||||
setDraft: (next: LiveNote) => void
|
||||
editing: boolean
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.focus()
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
}, [editing])
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={draft.objective}
|
||||
onChange={(e) => setDraft({ ...draft, objective: e.target.value })}
|
||||
onKeyDown={onKeyDown}
|
||||
spellCheck
|
||||
placeholder="Keep this note updated with…"
|
||||
className="flex-1 resize-none rounded-none border-0 border-transparent bg-transparent px-4 py-4 font-mono text-[12.5px] leading-relaxed shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto px-5 py-5">
|
||||
{draft.objective.trim() ? (
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{draft.objective}
|
||||
</Streamdown>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">No objective yet. Click Edit to write one.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailsTab({
|
||||
draft,
|
||||
setDraft,
|
||||
editingEvents,
|
||||
setEditingEvents,
|
||||
showAdvanced,
|
||||
setShowAdvanced,
|
||||
confirmingDelete,
|
||||
setConfirmingDelete,
|
||||
onDelete,
|
||||
saving,
|
||||
}: {
|
||||
draft: LiveNote
|
||||
setDraft: (next: LiveNote) => void
|
||||
editingEvents: boolean
|
||||
setEditingEvents: (v: boolean) => void
|
||||
showAdvanced: boolean
|
||||
setShowAdvanced: (v: boolean) => void
|
||||
confirmingDelete: boolean
|
||||
setConfirmingDelete: (v: boolean) => void
|
||||
onDelete: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SectionRegion label="Triggers">
|
||||
<TriggersEditor
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
editingEvents={editingEvents}
|
||||
setEditingEvents={setEditingEvents}
|
||||
/>
|
||||
</SectionRegion>
|
||||
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
{showAdvanced ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
Advanced
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
|
||||
<span className="pt-1.5 text-muted-foreground">Model</span>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, model: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
<span className="pt-1.5 text-muted-foreground">Provider</span>
|
||||
<Input
|
||||
value={draft.provider ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, provider: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
|
||||
<span className="text-destructive">Convert to static note?</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onDelete} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
Convert
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
className="text-xs font-medium text-destructive hover:underline"
|
||||
>
|
||||
Convert to static note →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionRegion({ label, children }: { label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 py-4 last:border-b-0">
|
||||
{label && (
|
||||
<div className="mb-3 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LastRunTab({ live }: { live: LiveNote }) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [loadingRun, setLoadingRun] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
const runId = live.lastRunId ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setRun(null)
|
||||
setFetchError(null)
|
||||
setLoadingRun(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoadingRun(true)
|
||||
setFetchError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setFetchError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoadingRun(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
if (!runId) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-12 text-center">
|
||||
<p className="text-xs text-muted-foreground max-w-[240px]">
|
||||
No run yet. Click <span className="font-medium text-foreground">Run now</span> below to see the agent's full transcript here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isError = !!live.lastRunError
|
||||
const items = run ? runLogToConversation(run.log) : []
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
|
||||
{/* Summary header — timestamp + summary markdown / error. */}
|
||||
<div>
|
||||
{live.lastRunAt && (
|
||||
<div className="mb-2 font-mono text-[10.5px] text-muted-foreground">
|
||||
{formatRunAt(live.lastRunAt)} · {formatRelativeTime(live.lastRunAt)} ago
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-2">
|
||||
<AlertCircle className="size-3.5 shrink-0 mt-0.5 text-destructive" />
|
||||
<code className="break-all font-mono text-[11px] leading-relaxed text-destructive">
|
||||
{live.lastRunError}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{live.lastRunSummary && (
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/85 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-2 [&_ul]:my-2 [&_ol]:my-2">
|
||||
{live.lastRunSummary}
|
||||
</Streamdown>
|
||||
)}
|
||||
{!isError && !live.lastRunSummary && (
|
||||
<p className="text-xs italic text-muted-foreground">No summary recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Full transcript */}
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Transcript
|
||||
</div>
|
||||
{loadingRun && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{fetchError && !loadingRun && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Couldn't load transcript: {fetchError}
|
||||
</div>
|
||||
)}
|
||||
{run && !loadingRun && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loadingRun && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function TriggersEditor({
|
||||
draft,
|
||||
setDraft,
|
||||
editingEvents,
|
||||
setEditingEvents,
|
||||
}: {
|
||||
draft: LiveNote
|
||||
setDraft: (next: LiveNote) => void
|
||||
editingEvents: boolean
|
||||
setEditingEvents: (v: boolean) => void
|
||||
}) {
|
||||
const triggers: Triggers = draft.triggers ?? {}
|
||||
const hasCron = typeof triggers.cronExpr === 'string'
|
||||
const hasWindows = Array.isArray(triggers.windows) && triggers.windows.length > 0
|
||||
const hasEvent = typeof triggers.eventMatchCriteria === 'string'
|
||||
|
||||
const updateTriggers = (next: Partial<Triggers>) => {
|
||||
const merged: Triggers = { ...triggers, ...next }
|
||||
;(Object.keys(merged) as (keyof Triggers)[]).forEach(key => {
|
||||
if (merged[key] === undefined) delete merged[key]
|
||||
})
|
||||
if (Object.keys(merged).length === 0) {
|
||||
const { triggers: _omit, ...rest } = draft
|
||||
setDraft(rest as LiveNote)
|
||||
} else {
|
||||
setDraft({ ...draft, triggers: merged })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[74px_1fr] items-start gap-x-3 gap-y-4">
|
||||
{/* Cron */}
|
||||
<div className="flex items-center gap-1.5 pt-1.5 text-xs text-muted-foreground">
|
||||
<Repeat className="size-3.5" /> Cron
|
||||
</div>
|
||||
<div>
|
||||
{hasCron ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={triggers.cronExpr ?? ''}
|
||||
onChange={(e) => updateTriggers({ cronExpr: e.target.value })}
|
||||
placeholder="0 * * * *"
|
||||
className="h-7 max-w-[160px] font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTriggers({ cronExpr: undefined })}
|
||||
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Remove cron"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
{triggers.cronExpr && (
|
||||
<div className="text-[11px] text-muted-foreground">{describeCron(triggers.cronExpr)}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTriggers({ cronExpr: '0 * * * *' })}
|
||||
className="inline-flex items-center gap-1 pt-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3" /> Cron
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Windows */}
|
||||
<div className="flex items-center gap-1.5 pt-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="size-3.5" /> Windows
|
||||
</div>
|
||||
<div>
|
||||
{hasWindows && triggers.windows ? (
|
||||
<div className="space-y-1.5">
|
||||
{triggers.windows.map((w, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={w.startTime}
|
||||
onChange={(e) => {
|
||||
const next = [...(triggers.windows ?? [])]
|
||||
next[idx] = { ...next[idx], startTime: e.target.value }
|
||||
updateTriggers({ windows: next })
|
||||
}}
|
||||
placeholder="09:00"
|
||||
className={`h-7 w-20 font-mono text-xs ${HH_MM.test(w.startTime) ? '' : 'border-destructive'}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">–</span>
|
||||
<Input
|
||||
value={w.endTime}
|
||||
onChange={(e) => {
|
||||
const next = [...(triggers.windows ?? [])]
|
||||
next[idx] = { ...next[idx], endTime: e.target.value }
|
||||
updateTriggers({ windows: next })
|
||||
}}
|
||||
placeholder="12:00"
|
||||
className={`h-7 w-20 font-mono text-xs ${HH_MM.test(w.endTime) ? '' : 'border-destructive'}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = (triggers.windows ?? []).filter((_, i) => i !== idx)
|
||||
updateTriggers({ windows: next.length === 0 ? undefined : next })
|
||||
}}
|
||||
className="ml-auto inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Remove window"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTriggers({
|
||||
windows: [...(triggers.windows ?? []), { startTime: '13:00', endTime: '15:00' }],
|
||||
})}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3" /> Window
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTriggers({ windows: [{ startTime: '09:00', endTime: '12:00' }] })}
|
||||
className="inline-flex items-center gap-1 pt-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3" /> Window
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="flex items-center gap-1.5 pt-1.5 text-xs text-muted-foreground">
|
||||
<Zap className="size-3.5" /> Events
|
||||
</div>
|
||||
<div>
|
||||
{hasEvent ? (
|
||||
editingEvents ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea
|
||||
value={triggers.eventMatchCriteria ?? ''}
|
||||
onChange={(e) => updateTriggers({ eventMatchCriteria: e.target.value })}
|
||||
rows={5}
|
||||
autoFocus
|
||||
placeholder="Emails or calendar events about…"
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingEvents(false)}
|
||||
className="text-[11px] font-medium text-foreground hover:underline"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTriggers({ eventMatchCriteria: undefined })
|
||||
setEditingEvents(false)
|
||||
}}
|
||||
className="text-[11px] text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs leading-relaxed text-foreground/85">
|
||||
{triggers.eventMatchCriteria || <span className="italic text-muted-foreground">No criteria yet.</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingEvents(true)}
|
||||
className="ml-1 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{triggers.eventMatchCriteria ? 'Edit rule →' : 'Add →'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTriggers({ eventMatchCriteria: '' })
|
||||
setEditingEvents(true)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 pt-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3" /> Event rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
344
apps/x/apps/renderer/src/components/live-notes-view.tsx
Normal file
344
apps/x/apps/renderer/src/components/live-notes-view.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Radio, Loader2, Square, AlertCircle } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
|
||||
type LiveNoteRow = {
|
||||
path: string
|
||||
createdAt: string | null
|
||||
lastRunAt: string | null
|
||||
isActive: boolean
|
||||
objective: string
|
||||
lastRunError?: string | null
|
||||
lastAttemptAt?: string | null
|
||||
}
|
||||
|
||||
type LiveNotesViewProps = {
|
||||
onOpenNote: (path: string) => void
|
||||
onAddNewLiveNote: () => void
|
||||
}
|
||||
|
||||
function formatDateLabel(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatLastRanLabel(iso: string | null): string {
|
||||
if (!iso) return 'Never'
|
||||
return formatRelativeTime(iso) || 'Never'
|
||||
}
|
||||
|
||||
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
|
||||
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
|
||||
}
|
||||
|
||||
export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewProps) {
|
||||
const [notes, setNotes] = useState<LiveNoteRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
|
||||
const [stoppingPaths, setStoppingPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
const agentStatus = useLiveNoteAgentStatus()
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('live-note:listNotes', null)
|
||||
// listNotes returns the summary fields; we also want lastRunError +
|
||||
// lastAttemptAt so the rows can render the error/running state. The
|
||||
// current IPC summary doesn't include them — fetch those per-note in
|
||||
// parallel so the rows can render fully.
|
||||
const enriched = await Promise.all(result.notes.map(async (n) => {
|
||||
const knowledgeRel = n.path.replace(/^knowledge\//, '')
|
||||
try {
|
||||
const detail = await window.ipc.invoke('live-note:get', { filePath: knowledgeRel })
|
||||
if (detail.success && detail.live) {
|
||||
return {
|
||||
...n,
|
||||
lastRunError: detail.live.lastRunError ?? null,
|
||||
lastAttemptAt: detail.live.lastAttemptAt ?? null,
|
||||
} satisfies LiveNoteRow
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return n satisfies LiveNoteRow
|
||||
}))
|
||||
setNotes(enriched)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load live notes:', err)
|
||||
setError('Could not load live notes.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadNotes()
|
||||
}, [loadNotes])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null
|
||||
void loadNotes()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
|
||||
break
|
||||
case 'moved':
|
||||
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
|
||||
scheduleReload()
|
||||
}
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
|
||||
scheduleReload()
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const cleanupAgentEvents = window.ipc.on('live-note-agent:events', () => {
|
||||
scheduleReload()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupWorkspace()
|
||||
cleanupAgentEvents()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}, [loadNotes])
|
||||
|
||||
const handleToggleState = useCallback(async (note: LiveNoteRow, active: boolean) => {
|
||||
setUpdatingPaths((prev) => new Set(prev).add(note.path))
|
||||
try {
|
||||
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
|
||||
const result = await window.ipc.invoke('live-note:setActive', {
|
||||
filePath: knowledgeRelative,
|
||||
active,
|
||||
})
|
||||
|
||||
if (!result.success || !result.live) {
|
||||
throw new Error(result.error ?? 'Failed to update live-note state')
|
||||
}
|
||||
|
||||
setNotes((prev) => prev.map((entry) => (
|
||||
entry.path === note.path
|
||||
? {
|
||||
...entry,
|
||||
isActive: result.live!.active !== false,
|
||||
lastRunAt: result.live!.lastRunAt ?? entry.lastRunAt,
|
||||
lastRunError: result.live!.lastRunError ?? null,
|
||||
lastAttemptAt: result.live!.lastAttemptAt ?? entry.lastAttemptAt,
|
||||
}
|
||||
: entry
|
||||
)))
|
||||
} catch (err) {
|
||||
console.error('Failed to update live-note state:', err)
|
||||
toast(err instanceof Error ? err.message : 'Failed to update live-note state', 'error')
|
||||
} finally {
|
||||
setUpdatingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.path)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStop = useCallback(async (note: LiveNoteRow) => {
|
||||
setStoppingPaths((prev) => new Set(prev).add(note.path))
|
||||
try {
|
||||
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
|
||||
const result = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelative })
|
||||
if (!result.success && result.error) {
|
||||
toast(result.error, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Failed to stop run', 'error')
|
||||
} finally {
|
||||
setStoppingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.path)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Live notes</h2>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={onAddNewLiveNote}>
|
||||
New live note
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Notes whose body is kept current by an agent. Toggle a note inactive to pause its agent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Radio className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Radio className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No live notes yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[50%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[20%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/30 text-left">
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map((note) => {
|
||||
const isUpdating = updatingPaths.has(note.path)
|
||||
const isStopping = stoppingPaths.has(note.path)
|
||||
const knowledgeRel = note.path.replace(/^knowledge\//, '')
|
||||
const runState = agentStatus.get(knowledgeRel)
|
||||
const isRunning = runState?.status === 'running'
|
||||
const objectivePreview = note.objective.split('\n')[0].trim()
|
||||
const hasError = !isRunning && !!note.lastRunError
|
||||
return (
|
||||
<tr
|
||||
key={note.path}
|
||||
className={`border-b border-border/50 last:border-b-0 transition-colors ${isRunning ? 'bg-primary/5' : 'hover:bg-muted/20'}`}
|
||||
>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasError && (
|
||||
<AlertCircle
|
||||
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
aria-label="Last run failed"
|
||||
>
|
||||
<title>Last run failed: {note.lastRunError}</title>
|
||||
</AlertCircle>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
|
||||
title={note.path}
|
||||
>
|
||||
{wikiLabel(note.path)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{stripKnowledgePrefix(note.path)}
|
||||
</div>
|
||||
{objectivePreview && (
|
||||
<div className="truncate text-xs text-muted-foreground/80" title={note.objective}>
|
||||
{objectivePreview}
|
||||
</div>
|
||||
)}
|
||||
{hasError && note.lastRunError && (
|
||||
<div className="truncate text-xs text-amber-600 dark:text-amber-400" title={note.lastRunError}>
|
||||
{note.lastRunError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground/80">
|
||||
{formatDateLabel(note.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground/80">
|
||||
{formatLastRanLabel(note.lastRunAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Updating…
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleStop(note)}
|
||||
disabled={isStopping}
|
||||
>
|
||||
{isStopping ? <Loader2 className="size-3 animate-spin" /> : <Square className="size-3" />}
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
{isUpdating ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<Switch
|
||||
checked={note.isActive}
|
||||
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<span className="min-w-16 text-xs font-medium text-foreground/80">
|
||||
{note.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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*<!--track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
.replace(
|
||||
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
}
|
||||
|
||||
// Post-process to clean up any zero-width spaces in the output
|
||||
function postprocessMarkdown(markdown: string): string {
|
||||
// Remove lines that contain only the zero-width space marker
|
||||
|
|
@ -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 `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'trackTargetClose':
|
||||
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
|
|
@ -328,7 +290,9 @@ function computeWithinBlockOffset(
|
|||
return 0
|
||||
}
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
import { EditorToolbar, type LivePillState } from './editor-toolbar'
|
||||
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
|
|
@ -697,22 +661,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
PromptBlockExtension.configure({ notePath }),
|
||||
TrackTargetOpenExtension,
|
||||
TrackTargetCloseExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
EmailsBlockExtension,
|
||||
EmailBlockExtension,
|
||||
TranscriptBlockExtension,
|
||||
MermaidBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
? (path: string) => {
|
||||
void wikiLinks.onCreate(path)
|
||||
}
|
||||
: undefined,
|
||||
|
|
@ -1099,9 +1061,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines, 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
|
||||
|
|
@ -1464,6 +1424,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
return createImageUploadHandler(editor, onImageUpload)
|
||||
}, [editor, onImageUpload])
|
||||
|
||||
// Live-note pill state for the toolbar — derived from the on-disk `live:`
|
||||
// block plus the agent-status bus. The `tick` dependency keeps the relative
|
||||
// time label fresh as minutes roll over.
|
||||
const { live: currentLive, isRunning: liveIsRunning, tick: liveTick } = useLiveNoteForPath(notePath)
|
||||
const livePillStateForCurrentNote: LivePillState = useMemo(() => {
|
||||
void liveTick // re-run on tick to refresh relative-time label
|
||||
if (!currentLive) return { variant: 'passive', label: 'Make live' }
|
||||
if (liveIsRunning) return { variant: 'running', label: 'Updating…' }
|
||||
if (currentLive.lastRunError) {
|
||||
const when = currentLive.lastAttemptAt ? formatRelativeTime(currentLive.lastAttemptAt) : ''
|
||||
return { variant: 'error', label: when ? `Live · failed ${when}` : 'Live · failed' }
|
||||
}
|
||||
if (currentLive.active === false) return { variant: 'passive', label: 'Live · paused' }
|
||||
if (currentLive.lastRunAt) {
|
||||
const when = formatRelativeTime(currentLive.lastRunAt)
|
||||
return { variant: 'idle', label: when ? `Live · ${when}` : 'Live' }
|
||||
}
|
||||
return { variant: 'idle', label: 'Live · never run' }
|
||||
}, [currentLive, liveIsRunning, liveTick])
|
||||
|
||||
return (
|
||||
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
||||
<EditorToolbar
|
||||
|
|
@ -1471,6 +1451,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
onOpenLiveNote={notePath ? () => {
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
|
||||
detail: { filePath: notePath },
|
||||
}))
|
||||
} : undefined}
|
||||
liveState={notePath ? livePillStateForCurrentNote : undefined}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
|
|
|
|||
778
apps/x/apps/renderer/src/components/meetings-view.tsx
Normal file
778
apps/x/apps/renderer/src/components/meetings-view.tsx
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MeetingTranscriptionState } from '@/hooks/useMeetingTranscription'
|
||||
|
||||
const MEETINGS_ROOT = 'knowledge/Meetings'
|
||||
const CALENDAR_DIR = 'calendar_sync'
|
||||
const UPCOMING_MAX_DAYS = 4 // today + next 3
|
||||
|
||||
type MeetingNoteRow = {
|
||||
path: string
|
||||
name: string
|
||||
dateLabel: string
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
type MeetingsViewProps = {
|
||||
onOpenNote: (path: string) => void
|
||||
onTakeMeetingNotes: () => void
|
||||
meetingState: MeetingTranscriptionState
|
||||
meetingSummarizing?: boolean
|
||||
}
|
||||
|
||||
function isMeetingPath(path: string | undefined): boolean {
|
||||
return typeof path === 'string' && (path === MEETINGS_ROOT || path.startsWith(`${MEETINGS_ROOT}/`))
|
||||
}
|
||||
|
||||
function isCalendarPath(path: string | undefined): boolean {
|
||||
return typeof path === 'string' && (path === CALENDAR_DIR || path.startsWith(`${CALENDAR_DIR}/`))
|
||||
}
|
||||
|
||||
type RawCalendarEvent = {
|
||||
id?: string
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string }
|
||||
end?: { dateTime?: string; date?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
status?: string
|
||||
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>
|
||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||
hangoutLink?: string
|
||||
conferenceLink?: string
|
||||
}
|
||||
|
||||
type UpcomingEvent = {
|
||||
id: string
|
||||
summary: string
|
||||
start: Date
|
||||
end: Date | null
|
||||
isAllDay: boolean
|
||||
location: string | null
|
||||
htmlLink: string | null
|
||||
conferenceLink: string | null
|
||||
source: string // workspace path to the calendar_sync JSON
|
||||
rawStart: { dateTime?: string; date?: string } | undefined
|
||||
rawEnd: { dateTime?: string; date?: string } | undefined
|
||||
dateKey: string // YYYY-MM-DD (local)
|
||||
}
|
||||
|
||||
type DayGroup = {
|
||||
dateKey: string
|
||||
date: Date // local start-of-day
|
||||
events: UpcomingEvent[]
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const out = new Date(d)
|
||||
out.setHours(0, 0, 0, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const out = new Date(d)
|
||||
out.setDate(out.getDate() + n)
|
||||
return out
|
||||
}
|
||||
|
||||
function localDateKey(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
// Parse an all-day calendar date string ("YYYY-MM-DD") into a local Date at midnight.
|
||||
function parseAllDayDate(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (!m) return null
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEvent | null {
|
||||
if (raw.status === 'cancelled') return null
|
||||
const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined'
|
||||
if (declined) return null
|
||||
|
||||
const allDayStart = raw.start?.date
|
||||
const timedStart = raw.start?.dateTime
|
||||
const isAllDay = !timedStart && Boolean(allDayStart)
|
||||
|
||||
let start: Date | null = null
|
||||
let end: Date | null = null
|
||||
if (timedStart) {
|
||||
start = new Date(timedStart)
|
||||
end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null
|
||||
} else if (allDayStart) {
|
||||
start = parseAllDayDate(allDayStart)
|
||||
// Google's all-day end is exclusive (next day at 00:00) — keep as-is.
|
||||
end = raw.end?.date ? parseAllDayDate(raw.end.date) : null
|
||||
}
|
||||
if (!start || Number.isNaN(start.getTime())) return null
|
||||
|
||||
const conferenceLink = extractConferenceLink(raw as unknown as Record<string, unknown>) ?? null
|
||||
|
||||
return {
|
||||
id: raw.id ?? sourcePath,
|
||||
summary: raw.summary?.trim() || '(No title)',
|
||||
start,
|
||||
end,
|
||||
isAllDay,
|
||||
location: raw.location?.trim() || null,
|
||||
htmlLink: raw.htmlLink ?? null,
|
||||
conferenceLink,
|
||||
source: sourcePath,
|
||||
rawStart: raw.start,
|
||||
rawEnd: raw.end,
|
||||
dateKey: localDateKey(start),
|
||||
}
|
||||
}
|
||||
|
||||
function triggerMeetingCapture(event: UpcomingEvent, openConference: boolean) {
|
||||
window.__pendingCalendarEvent = {
|
||||
summary: event.summary,
|
||||
start: event.rawStart,
|
||||
end: event.rawEnd,
|
||||
location: event.location ?? undefined,
|
||||
htmlLink: event.htmlLink ?? undefined,
|
||||
conferenceLink: event.conferenceLink ?? undefined,
|
||||
source: event.source,
|
||||
}
|
||||
if (openConference && event.conferenceLink) {
|
||||
window.open(event.conferenceLink, '_blank')
|
||||
}
|
||||
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||
}
|
||||
|
||||
// Always show today (anchor). For days within the window after today, include
|
||||
// only those that actually have events — skip empty days.
|
||||
function selectVisibleDays(allDays: DayGroup[]): DayGroup[] {
|
||||
if (allDays.length === 0) return []
|
||||
const out: DayGroup[] = [allDays[0]]
|
||||
const cap = Math.min(allDays.length, UPCOMING_MAX_DAYS)
|
||||
for (let i = 1; i < cap; i++) {
|
||||
if (allDays[i].events.length > 0) out.push(allDays[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function buildDayWindow(now: Date): DayGroup[] {
|
||||
const today = startOfDay(now)
|
||||
return Array.from({ length: UPCOMING_MAX_DAYS }, (_, i) => {
|
||||
const date = addDays(today, i)
|
||||
return { dateKey: localDateKey(date), date, events: [] }
|
||||
})
|
||||
}
|
||||
|
||||
function formatEventTimeRange(event: UpcomingEvent): string {
|
||||
if (event.isAllDay) return 'All day'
|
||||
const start = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
if (!event.end) return start
|
||||
// If start and end are on different days, show date+time on both ends.
|
||||
const sameDay = localDateKey(event.start) === localDateKey(event.end)
|
||||
if (!sameDay) {
|
||||
const startLong = event.start.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
const endLong = event.end.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
return `${startLong} – ${endLong}`
|
||||
}
|
||||
const end = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return `${start} – ${end}`
|
||||
}
|
||||
|
||||
function UpcomingEvents() {
|
||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const exists = await window.ipc.invoke('workspace:exists', { path: CALENDAR_DIR })
|
||||
if (!exists.exists) {
|
||||
setEvents([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
const entries = await window.ipc.invoke('workspace:readdir', {
|
||||
path: CALENDAR_DIR,
|
||||
opts: { recursive: false, includeHidden: false, includeStats: false },
|
||||
})
|
||||
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
|
||||
|
||||
const now = new Date()
|
||||
const todayStart = startOfDay(now)
|
||||
const windowEnd = addDays(todayStart, UPCOMING_MAX_DAYS) // exclusive
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
jsonEntries.map(async (entry): Promise<UpcomingEvent | null> => {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: entry.path,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const raw = JSON.parse(result.data) as RawCalendarEvent
|
||||
const ev = normalizeEvent(raw, entry.path)
|
||||
if (!ev) return null
|
||||
// Event must overlap the [now, windowEnd) range — i.e. not already ended,
|
||||
// and not start after the window closes.
|
||||
const effectiveEnd = ev.end ?? (ev.isAllDay ? addDays(ev.start, 1) : ev.start)
|
||||
if (effectiveEnd <= now) return null
|
||||
if (ev.start >= windowEnd) return null
|
||||
return ev
|
||||
}),
|
||||
)
|
||||
|
||||
const collected: UpcomingEvent[] = []
|
||||
for (const r of settled) {
|
||||
if (r.status === 'fulfilled' && r.value) collected.push(r.value)
|
||||
}
|
||||
collected.sort((a, b) => {
|
||||
if (a.isAllDay !== b.isAllDay) return a.isAllDay ? -1 : 1
|
||||
return a.start.getTime() - b.start.getTime()
|
||||
})
|
||||
setEvents(collected)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load upcoming events:', err)
|
||||
setError('Could not load upcoming events.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadEvents()
|
||||
}, [loadEvents, refreshTick])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const scheduleReload = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null
|
||||
setRefreshTick((t) => t + 1)
|
||||
}, 250)
|
||||
}
|
||||
const cleanup = window.ipc.on('workspace:didChange', (event) => {
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isCalendarPath(event.path)) scheduleReload()
|
||||
break
|
||||
case 'moved':
|
||||
if (isCalendarPath(event.from) || isCalendarPath(event.to)) scheduleReload()
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!event.paths || event.paths.some(isCalendarPath)) scheduleReload()
|
||||
break
|
||||
}
|
||||
})
|
||||
// Refresh on the hour so day labels and "ended" filtering stay current.
|
||||
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000)
|
||||
return () => {
|
||||
cleanup()
|
||||
clearInterval(tick)
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleDays = useMemo(() => {
|
||||
const window = buildDayWindow(new Date())
|
||||
const byKey = new Map(window.map((d) => [d.dateKey, d]))
|
||||
for (const ev of events) {
|
||||
byKey.get(ev.dateKey)?.events.push(ev)
|
||||
}
|
||||
return selectVisibleDays(window)
|
||||
}, [events])
|
||||
|
||||
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
|
||||
const now = new Date()
|
||||
const todayKey = localDateKey(now)
|
||||
|
||||
return (
|
||||
<section className="border-b border-border/60 px-6 pb-6 pt-5">
|
||||
<div className="mx-auto w-full max-w-[760px]">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Calendar className="size-4 text-muted-foreground" />
|
||||
Coming up
|
||||
</h3>
|
||||
{loading && events.length === 0 ? null : (
|
||||
<span
|
||||
className="text-[11px] uppercase tracking-wider"
|
||||
style={{ color: 'var(--gm-text-faint)' }}
|
||||
>
|
||||
{totalVisible} {totalVisible === 1 ? 'event' : 'events'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && events.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-4 text-sm text-muted-foreground">{error}</div>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ borderColor: 'var(--gm-border)', background: 'var(--gm-bg)' }}
|
||||
>
|
||||
{visibleDays.map((day, idx) => (
|
||||
<UpcomingDayRow
|
||||
key={day.dateKey}
|
||||
day={day}
|
||||
isToday={day.dateKey === todayKey}
|
||||
isLast={idx === visibleDays.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) {
|
||||
const dayNum = day.date.getDate()
|
||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '96px 1fr',
|
||||
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 px-4 py-4">
|
||||
<span
|
||||
className="leading-none"
|
||||
style={{ fontSize: 30, fontWeight: 400, color: 'var(--gm-text-strong)' }}
|
||||
>
|
||||
{dayNum}
|
||||
</span>
|
||||
<span className="flex flex-col leading-tight">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
style={{ fontSize: 12, fontWeight: 600, color: 'var(--gm-text)' }}
|
||||
>
|
||||
{month}
|
||||
{isToday ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block rounded-full"
|
||||
style={{ width: 5, height: 5, background: 'var(--gm-accent)' }}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col py-3 pr-3">
|
||||
{day.events.length === 0 ? (
|
||||
<div
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
|
||||
style={{ color: 'var(--gm-text-faint)', minHeight: 40 }}
|
||||
>
|
||||
<span aria-hidden className="self-stretch shrink-0" style={{ width: 3 }} />
|
||||
<span>{isToday ? 'No events today' : 'No events'}</span>
|
||||
</div>
|
||||
) : (
|
||||
day.events.map((ev) => <UpcomingEventItem key={ev.id} event={ev} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
|
||||
const handleOpen = useCallback(() => {
|
||||
if (event.htmlLink) window.open(event.htmlLink, '_blank')
|
||||
}, [event.htmlLink])
|
||||
|
||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen()
|
||||
}
|
||||
}}
|
||||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
|
||||
)}
|
||||
style={{ color: 'var(--gm-text)', minHeight: 40 }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="self-stretch rounded-full"
|
||||
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate"
|
||||
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
|
||||
>
|
||||
{event.summary}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 block truncate"
|
||||
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
|
||||
>
|
||||
{formatEventTimeRange(event)}
|
||||
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
onNotesOnly={() => triggerMeetingCapture(event, false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
style={{
|
||||
background: 'var(--gm-bg-pill)',
|
||||
color: 'var(--gm-text)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||
>
|
||||
<Mic className="size-3" />
|
||||
Take notes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
|
||||
onJoinAndNotes: () => void
|
||||
onNotesOnly: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target
|
||||
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: 'relative', display: 'inline-flex', alignItems: 'stretch' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs transition-colors"
|
||||
style={{
|
||||
background: 'var(--gm-bg-pill)',
|
||||
color: 'var(--gm-text)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
borderTopLeftRadius: 6,
|
||||
borderBottomLeftRadius: 6,
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||
>
|
||||
<Video className="size-3" />
|
||||
Join & take notes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
|
||||
aria-label="More meeting options"
|
||||
className="inline-flex items-center justify-center px-1.5 py-1 transition-colors"
|
||||
style={{
|
||||
background: 'var(--gm-bg-pill)',
|
||||
color: 'var(--gm-text)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
borderLeft: 'none',
|
||||
borderTopRightRadius: 6,
|
||||
borderBottomRightRadius: 6,
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
background: 'var(--gm-bg-card)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
|
||||
minWidth: 144,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
|
||||
className="flex w-full items-center gap-1 px-2 py-1.5 text-xs"
|
||||
style={{ background: 'transparent', color: 'var(--gm-text)', whiteSpace: 'nowrap', border: 'none' }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-row-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<Mic className="size-3" />
|
||||
Take notes only
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMeetingName(name: string): string {
|
||||
return name.replace(/\.md$/i, '').replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function formatDateLabel(label: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(label)) return label || '—'
|
||||
const date = new Date(`${label}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) return label
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getMeetingButtonLabel(state: MeetingTranscriptionState): string {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
return 'Starting...'
|
||||
case 'recording':
|
||||
return 'Stop recording'
|
||||
case 'stopping':
|
||||
return 'Stopping...'
|
||||
case 'idle':
|
||||
default:
|
||||
return 'Take meeting notes'
|
||||
}
|
||||
}
|
||||
|
||||
export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, meetingSummarizing = false }: MeetingsViewProps) {
|
||||
const [notes, setNotes] = useState<MeetingNoteRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const exists = await window.ipc.invoke('workspace:exists', { path: MEETINGS_ROOT })
|
||||
if (!exists.exists) {
|
||||
setNotes([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await window.ipc.invoke('workspace:readdir', {
|
||||
path: MEETINGS_ROOT,
|
||||
opts: {
|
||||
recursive: true,
|
||||
includeHidden: false,
|
||||
includeStats: true,
|
||||
},
|
||||
})
|
||||
|
||||
const rows = entries
|
||||
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
|
||||
.map((entry) => {
|
||||
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
||||
const parts = relative.split('/')
|
||||
const dateFolder = parts.find((part) => /^\d{4}-\d{2}-\d{2}$/.test(part)) ?? ''
|
||||
return {
|
||||
path: entry.path,
|
||||
name: formatMeetingName(entry.name),
|
||||
dateLabel: formatDateLabel(dateFolder),
|
||||
mtimeMs: entry.stat?.mtimeMs ?? 0,
|
||||
} satisfies MeetingNoteRow
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs
|
||||
return b.path.localeCompare(a.path)
|
||||
})
|
||||
|
||||
setNotes(rows)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load meetings:', err)
|
||||
setError('Could not load meeting notes.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadNotes()
|
||||
}, [loadNotes])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null
|
||||
void loadNotes()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const cleanup = window.ipc.on('workspace:didChange', (event) => {
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isMeetingPath(event.path)) scheduleReload()
|
||||
break
|
||||
case 'moved':
|
||||
if (isMeetingPath(event.from) || isMeetingPath(event.to)) {
|
||||
scheduleReload()
|
||||
}
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!event.paths || event.paths.some(isMeetingPath)) {
|
||||
scheduleReload()
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}, [loadNotes])
|
||||
|
||||
const isBusy = meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing
|
||||
const isRecording = meetingState === 'recording'
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Meetings</h2>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isRecording ? 'destructive' : 'default'}
|
||||
disabled={isBusy}
|
||||
onClick={onTakeMeetingNotes}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' || meetingState === 'stopping' ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : isRecording ? (
|
||||
<Square className="mr-2 size-3.5" />
|
||||
) : (
|
||||
<Mic className="mr-2 size-4" />
|
||||
)}
|
||||
{meetingSummarizing ? 'Generating notes...' : getMeetingButtonLabel(meetingState)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Upcoming events and meeting notes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<UpcomingEvents />
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center px-8 py-10 text-center text-sm text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-8 py-10 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Mic className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No meeting notes yet. Use <strong>Take meeting notes</strong> to start one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[56%]" />
|
||||
<col className="w-[20%]" />
|
||||
<col className="w-[24%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/30 text-left">
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Date</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map((note) => (
|
||||
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
<span className="block truncate">{note.name}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-sm text-muted-foreground">{note.dateLabel}</td>
|
||||
<td className="px-4 py-3 align-top text-sm text-muted-foreground">
|
||||
{note.mtimeMs > 0 ? (formatRelativeTime(new Date(note.mtimeMs).toISOString()) || '—') : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -59,14 +59,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -96,20 +96,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -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
|
||||
|
|
@ -458,6 +441,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -466,6 +451,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
liveNoteAgentModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -618,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)
|
||||
|
|
@ -1157,6 +1152,72 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
|
||||
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
|
|
@ -221,6 +221,76 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Meeting Notes Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Track Block Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -66,22 +66,22 @@ 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)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -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
|
||||
|
|
@ -435,6 +418,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -443,6 +428,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
liveNoteAgentModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -459,7 +446,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
|
|
@ -535,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
|
||||
}
|
||||
})
|
||||
|
|
@ -605,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)
|
||||
|
|
|
|||
56
apps/x/apps/renderer/src/components/pdf-file-viewer.tsx
Normal file
56
apps/x/apps/renderer/src/components/pdf-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
interface PdfFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'ready' | 'error'
|
||||
|
||||
export function PdfFileViewer({ path }: PdfFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileTextIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot preview this PDF</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<iframe
|
||||
key={path}
|
||||
src={src}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="PDF preview"
|
||||
onLoad={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading PDF…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState, type JSX } from 'react'
|
||||
import { HtmlFileViewer } from './html-file-viewer'
|
||||
import { PdfFileViewer } from './pdf-file-viewer'
|
||||
import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'
|
||||
|
||||
const CACHE_LIMIT = 3
|
||||
|
||||
function renderViewer(path: string): JSX.Element | null {
|
||||
const type = getViewerType(path)
|
||||
if (type === 'html') return <HtmlFileViewer path={path} />
|
||||
if (type === 'pdf') return <PdfFileViewer path={path} />
|
||||
return null
|
||||
}
|
||||
|
||||
interface PersistentViewerCacheProps {
|
||||
activePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps recently-opened HTML and PDF viewers mounted in the DOM,
|
||||
* toggling visibility instead of unmounting. This preserves iframe
|
||||
* state (PDF page/zoom, HTML scroll/JS state) across file switches.
|
||||
*/
|
||||
export function PersistentViewerCache({ activePath }: PersistentViewerCacheProps) {
|
||||
const [mountedPaths, setMountedPaths] = useState<string[]>(() =>
|
||||
isCacheableViewerPath(activePath) ? [activePath] : []
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCacheableViewerPath(activePath)) return
|
||||
setMountedPaths((prev) => {
|
||||
// Never reorder existing entries — moving a keyed iframe in the DOM
|
||||
// detaches it, which causes the browser to re-navigate (state lost).
|
||||
if (prev.includes(activePath)) return prev
|
||||
const next = [...prev, activePath]
|
||||
return next.length > CACHE_LIMIT ? next.slice(-CACHE_LIMIT) : next
|
||||
})
|
||||
}, [activePath])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{mountedPaths.map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="absolute inset-0"
|
||||
style={{ display: p === activePath ? 'block' : 'none' }}
|
||||
>
|
||||
{renderViewer(p)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx
Normal file
106
apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useEffect } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { TableKit } from '@tiptap/extension-table'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { PromptBlockExtension } from '@/extensions/prompt-block'
|
||||
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, EmailsBlockExtension } from '@/extensions/email-block'
|
||||
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
|
||||
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import '@/styles/editor.css'
|
||||
|
||||
const BLANK_LINE_MARKER = '\u200B'
|
||||
|
||||
function preprocessMarkdown(markdown: string): string {
|
||||
return markdown.replace(/\n{3,}/g, (match) => {
|
||||
const emptyParagraphs = match.length - 2
|
||||
let result = '\n\n'
|
||||
for (let i = 0; i < emptyParagraphs; i += 1) {
|
||||
result += BLANK_LINE_MARKER + '\n\n'
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export function RichMarkdownViewer({ content }: { content: string }) {
|
||||
const editor = useEditor({
|
||||
editable: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
HTMLAttributes: {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: 'editor-image',
|
||||
},
|
||||
}),
|
||||
TaskBlockExtension,
|
||||
PromptBlockExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
EmailsBlockExtension,
|
||||
EmailBlockExtension,
|
||||
TranscriptBlockExtension,
|
||||
MermaidBlockExtension,
|
||||
WikiLink,
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
TableKit.configure({
|
||||
table: { resizable: false },
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
breaks: true,
|
||||
tightLists: false,
|
||||
transformCopiedText: false,
|
||||
transformPastedText: false,
|
||||
}),
|
||||
],
|
||||
content: preprocessMarkdown(content),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none focus:outline-none',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessMarkdown(content)).run()
|
||||
}, [content, editor])
|
||||
|
||||
return (
|
||||
<div className="tiptap-editor rich-markdown-viewer">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<Mode>('chat')
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
|
||||
const chatInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -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({
|
|||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
|
||||
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
|
||||
title="Search"
|
||||
description="Search across knowledge and chats"
|
||||
showCloseButton={false}
|
||||
className="top-[20%] translate-y-0"
|
||||
>
|
||||
{/* Mode strip */}
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<ModeButton
|
||||
active={mode === 'chat'}
|
||||
onClick={() => setMode('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chat"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === 'search'}
|
||||
onClick={() => setMode('search')}
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Search"
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
|
||||
</div>
|
||||
|
||||
{mode === 'chat' ? (
|
||||
<div className="flex flex-col">
|
||||
<input
|
||||
ref={chatInputRef}
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// cmdk's Command component intercepts Enter for item selection — stop it
|
||||
// before bubbling so we control the chat submit ourselves.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
submitChat()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask copilot anything…"
|
||||
autoFocus
|
||||
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{contextChip && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
|
||||
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextChip(null)}
|
||||
aria-label="Remove context"
|
||||
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
{!contextChip && (
|
||||
<div className="flex items-center px-3 pb-3">
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
||||
function deriveDisplayName(path: string): string {
|
||||
const base = path.split('/').pop() ?? path
|
||||
return base.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
|
|
@ -370,17 +218,19 @@ function FilterToggle({
|
|||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export: thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
|
|
|||
|
|
@ -196,14 +196,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
|||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
|
|
@ -229,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
|
|
@ -302,6 +302,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||
models: savedModels,
|
||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||
meetingNotesModel: e.meetingNotesModel || "",
|
||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +320,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||
models: activeModels.length > 0 ? activeModels : [""],
|
||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
|
|
@ -391,6 +395,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
model: allModels[0] || "",
|
||||
models: allModels,
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -423,6 +429,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
model: allModels[0],
|
||||
models: allModels,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
})
|
||||
setDefaultProvider(prov)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -452,6 +460,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
parsed.model = defModels[0] || ""
|
||||
parsed.models = defModels
|
||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
|
|
@ -459,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
})
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -649,6 +659,74 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meeting notes model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track block model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -178,9 +179,12 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
{!billing.subscriptionPlan && (
|
||||
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
|
||||
)}
|
||||
{billing.subscriptionPlan === 'free' && (
|
||||
<p className="text-xs text-muted-foreground">Free usage resets daily at 00:00 UTC.</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -203,15 +207,15 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!billing?.subscriptionPlan}
|
||||
disabled={!hasPaidSubscription}
|
||||
onClick={() => appUrl && window.open(appUrl)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Manage in Stripe
|
||||
</Button>
|
||||
{!billing?.subscriptionPlan && (
|
||||
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
|
||||
{!hasPaidSubscription && (
|
||||
<p className="text-[11px] text-muted-foreground">Upgrade to a paid plan first</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -52,16 +52,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
c.setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
c.setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
c.startConnect(provider)
|
||||
}}
|
||||
onClick={() => c.handleReconnect(provider)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Reconnect
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ExternalLink,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
|
|
@ -24,7 +25,9 @@ import {
|
|||
Table2,
|
||||
Plug,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
LoaderIcon,
|
||||
Mail,
|
||||
Settings,
|
||||
Square,
|
||||
Trash2,
|
||||
|
|
@ -94,9 +97,9 @@ import { ConnectorsPopover } from "@/components/connectors-popover"
|
|||
import { HelpPopover } from "@/components/help-popover"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { toast } from "@/lib/toast"
|
||||
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
|
||||
import z from "zod"
|
||||
|
||||
interface TreeNode {
|
||||
|
|
@ -109,7 +112,7 @@ interface TreeNode {
|
|||
|
||||
type KnowledgeActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
openGraph: () => void
|
||||
openBases: () => void
|
||||
expandAll: () => void
|
||||
|
|
@ -117,9 +120,18 @@ type KnowledgeActions = {
|
|||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('mac')) return 'Finder'
|
||||
if (platform.includes('win')) return 'Explorer'
|
||||
return 'File Manager'
|
||||
}
|
||||
|
||||
type RunListItem = {
|
||||
id: string
|
||||
title?: string
|
||||
|
|
@ -156,6 +168,28 @@ const SERVICE_LABELS: Record<string, string> = {
|
|||
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<string, string> {
|
||||
const errors = new Map<string, string>()
|
||||
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 = {
|
||||
|
|
@ -182,14 +216,19 @@ type SidebarContentPanelProps = {
|
|||
selectedBackgroundTask?: string | null
|
||||
onNewChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
meetingState?: MeetingTranscriptionState
|
||||
meetingSummarizing?: boolean
|
||||
meetingAvailable?: boolean
|
||||
onToggleMeeting?: () => void
|
||||
isSearchOpen?: boolean
|
||||
isBrowserOpen?: boolean
|
||||
onToggleBrowser?: () => void
|
||||
isSuggestedTopicsOpen?: boolean
|
||||
onOpenSuggestedTopics?: () => void
|
||||
isMeetingsOpen?: boolean
|
||||
onOpenMeetings?: () => void
|
||||
isLiveNotesOpen?: boolean
|
||||
onOpenLiveNotes?: () => void
|
||||
isBgTasksOpen?: boolean
|
||||
onOpenBgTasks?: () => void
|
||||
isEmailOpen?: boolean
|
||||
onOpenEmail?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -203,28 +242,10 @@ function formatEventTime(ts: string): string {
|
|||
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
|
||||
function formatRunTime(ts: string): string {
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return ""
|
||||
const now = Date.now()
|
||||
const diffMs = Math.max(0, now - date.getTime())
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
|
||||
if (diffMinutes < 1) return "just now"
|
||||
if (diffMinutes < 60) return `${diffMinutes} m`
|
||||
if (diffHours < 24) return `${diffHours} h`
|
||||
if (diffDays < 7) return `${diffDays} d`
|
||||
if (diffWeeks < 4) return `${diffWeeks} w`
|
||||
return `${Math.max(1, diffMonths)} m`
|
||||
}
|
||||
|
||||
function SyncStatusBar() {
|
||||
const { state } = useSidebar()
|
||||
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
|
||||
const [serviceErrors, setServiceErrors] = useState<Map<string, string>>(new Map())
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
|
|
@ -258,11 +279,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 +331,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 +349,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 +379,16 @@ function SyncStatusBar() {
|
|||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-2 py-1 text-xs hover:bg-sidebar-accent",
|
||||
hasServiceErrors && !isSyncing ? "text-red-600 dark:text-red-400" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{isSyncing ? (
|
||||
<LoaderIcon className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : hasServiceErrors ? (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60" />
|
||||
)}
|
||||
|
|
@ -355,7 +406,7 @@ function SyncStatusBar() {
|
|||
<div className="p-3 border-b">
|
||||
<h4 className="font-semibold text-sm">Sync Activity</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isSyncing ? statusLabel : "All services up to date"}
|
||||
{isSyncing || hasServiceErrors ? statusLabel : "All services up to date"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-2">
|
||||
|
|
@ -387,7 +438,17 @@ function SyncStatusBar() {
|
|||
{SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leading-4 text-foreground/80">{event.message}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="leading-4 text-foreground/80">{event.message}</p>
|
||||
{event.type === 'error' && (
|
||||
<p
|
||||
className="truncate text-[11px] leading-4 text-red-600/90 dark:text-red-400/90"
|
||||
title={event.error}
|
||||
>
|
||||
{summarizeServiceError(event.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -416,14 +477,19 @@ export function SidebarContentPanel({
|
|||
selectedBackgroundTask,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState = 'idle',
|
||||
meetingSummarizing = false,
|
||||
meetingAvailable = false,
|
||||
onToggleMeeting,
|
||||
isSearchOpen = false,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
isSuggestedTopicsOpen = false,
|
||||
onOpenSuggestedTopics,
|
||||
isMeetingsOpen = false,
|
||||
onOpenMeetings,
|
||||
isLiveNotesOpen = false,
|
||||
onOpenLiveNotes,
|
||||
isBgTasksOpen = false,
|
||||
onOpenBgTasks,
|
||||
isEmailOpen = false,
|
||||
onOpenEmail,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -436,6 +502,12 @@ export function SidebarContentPanel({
|
|||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing } = useBilling(isRowboatConnected)
|
||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
|
||||
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
|
||||
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
|
||||
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
|
||||
|
||||
const handleRowboatLogin = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -494,13 +566,13 @@ export function SidebarContentPanel({
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r-0" {...props}>
|
||||
<Sidebar className="rowboat-sidebar border-r-0" {...props}>
|
||||
<SidebarHeader className="titlebar-drag-region">
|
||||
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
||||
<div className="h-8" />
|
||||
{/* Tab switcher - centered below the traffic lights row */}
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
<div className="titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5">
|
||||
<div className="rowboat-section-switcher titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5">
|
||||
{sectionTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
|
|
@ -518,7 +590,7 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
</div>
|
||||
{/* Quick action buttons */}
|
||||
<div className="titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
||||
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
||||
{onNewChat && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -533,40 +605,15 @@ export function SidebarContentPanel({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
)}
|
||||
{meetingAvailable && onToggleMeeting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMeeting}
|
||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
|
||||
meetingState === 'recording'
|
||||
? "text-red-500 hover:bg-sidebar-accent"
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSearchOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : meetingState === 'recording' ? (
|
||||
<Square className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Radio className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{meetingSummarizing
|
||||
? 'Generating notes…'
|
||||
: meetingState === 'connecting'
|
||||
? 'Starting…'
|
||||
: meetingState === 'recording'
|
||||
? 'Stop recording'
|
||||
: 'Take meeting notes'}
|
||||
</span>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
)}
|
||||
{onToggleBrowser && (
|
||||
|
|
@ -575,7 +622,7 @@ export function SidebarContentPanel({
|
|||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBrowserOpen
|
||||
isBrowserQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
|
|
@ -590,7 +637,7 @@ export function SidebarContentPanel({
|
|||
onClick={onOpenSuggestedTopics}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSuggestedTopicsOpen
|
||||
isSuggestedTopicsQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
|
|
@ -599,6 +646,66 @@ export function SidebarContentPanel({
|
|||
<span>Suggested Topics</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenBgTasks && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBgTasks}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBgTasksQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<ListChecks className="size-4" />
|
||||
<span>Background tasks</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenEmail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEmail}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isEmailQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Mail className="size-4" />
|
||||
<span>Email</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenMeetings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMeetings}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isMeetingsQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Mic className="size-4" />
|
||||
<span>Meetings</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenLiveNotes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLiveNotes}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isLiveNotesQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Radio className="size-4" />
|
||||
<span>Live notes</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
|
@ -645,7 +752,7 @@ export function SidebarContentPanel({
|
|||
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
|
||||
>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
|
||||
{!billing.subscriptionPlan || billing.subscriptionPlan === 'free' || billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1011,6 +1118,12 @@ function KnowledgeSection({
|
|||
}) {
|
||||
const isExpanded = expandedPaths.size > 0
|
||||
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
const visibleTree = React.useMemo(
|
||||
() => tree.filter((item) => item.path !== 'knowledge/Meetings'),
|
||||
[tree],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPath) return
|
||||
|
|
@ -1039,13 +1152,46 @@ function KnowledgeSection({
|
|||
cancelled = true
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [selectedPath, expandedPaths, tree])
|
||||
}, [selectedPath, expandedPaths, visibleTree])
|
||||
|
||||
// Folder clicks highlight the folder; file clicks clear folder highlight
|
||||
const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => {
|
||||
if (kind === 'dir') {
|
||||
setSelectedFolderPath(path)
|
||||
} else {
|
||||
setSelectedFolderPath(null)
|
||||
}
|
||||
onSelectFile(path, kind)
|
||||
}, [onSelectFile])
|
||||
|
||||
// Resolve the parent path for new items: explicit folder > open file's parent > root
|
||||
const deriveParent = React.useCallback((): string => {
|
||||
if (selectedFolderPath) return selectedFolderPath
|
||||
if (selectedPath) {
|
||||
const parts = selectedPath.split('/')
|
||||
if (parts.length > 1) return parts.slice(0, -1).join('/')
|
||||
}
|
||||
return 'knowledge'
|
||||
}, [selectedFolderPath, selectedPath])
|
||||
|
||||
// Wrap actions to inject context-aware parent and capture rename target
|
||||
const wrappedActions = React.useMemo<KnowledgeActions>(() => ({
|
||||
...actions,
|
||||
createNote: (parentPath?: string) => actions.createNote(parentPath ?? deriveParent()),
|
||||
createFolder: async (parentPath?: string): Promise<string> => {
|
||||
const newPath = await actions.createFolder(parentPath ?? deriveParent())
|
||||
setRenameTarget(newPath)
|
||||
return newPath
|
||||
},
|
||||
}), [actions, deriveParent])
|
||||
|
||||
const fileManagerName = getFileManagerName()
|
||||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||
{ icon: FilePlus, label: "New Note", action: () => wrappedActions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => void wrappedActions.createFolder() },
|
||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
|
||||
{ icon: FolderOpen, label: `Open in ${fileManagerName}`, action: () => actions.revealInFileManager('knowledge', true) },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -1088,15 +1234,18 @@ function KnowledgeSection({
|
|||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
||||
<div ref={treeContainerRef}>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
{visibleTree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
onSelect={handleSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
actions={wrappedActions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={() => setRenameTarget(null)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
|
@ -1105,11 +1254,11 @@ function KnowledgeSection({
|
|||
</SidebarGroup>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => actions.createNote()}>
|
||||
<ContextMenuItem onClick={() => wrappedActions.createNote()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.createFolder()}>
|
||||
<ContextMenuItem onClick={() => void wrappedActions.createFolder()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
|
|
@ -1134,6 +1283,9 @@ function Tree({
|
|||
onSelect,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
selectedFolderPath,
|
||||
renameTarget,
|
||||
onRenameTargetConsumed,
|
||||
}: {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
|
|
@ -1141,10 +1293,14 @@ function Tree({
|
|||
onSelect: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
actions: KnowledgeActions
|
||||
selectedFolderPath?: string | null
|
||||
renameTarget?: string | null
|
||||
onRenameTargetConsumed?: () => void
|
||||
}) {
|
||||
const isDir = item.kind === 'dir'
|
||||
const isExpanded = expandedPaths.has(item.path)
|
||||
const isSelected = selectedPath === item.path
|
||||
const isFolderSelected = isDir && selectedFolderPath === item.path
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const isSubmittingRef = React.useRef(false)
|
||||
const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name
|
||||
|
|
@ -1155,6 +1311,17 @@ function Tree({
|
|||
: item.name
|
||||
const [newName, setNewName] = useState(baseName)
|
||||
|
||||
// Auto-enter rename mode when this node is the rename target
|
||||
React.useEffect(() => {
|
||||
if (renameTarget === item.path) {
|
||||
setNewName(baseName)
|
||||
isSubmittingRef.current = false
|
||||
setIsRenaming(true)
|
||||
onRenameTargetConsumed?.()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [renameTarget, item.path])
|
||||
|
||||
// Sync newName when baseName changes (e.g., after external rename)
|
||||
React.useEffect(() => {
|
||||
setNewName(baseName)
|
||||
|
|
@ -1232,6 +1399,10 @@ function Tree({
|
|||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {getFileManagerName()}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
|
|
@ -1285,7 +1456,7 @@ function Tree({
|
|||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem className="group/file-item">
|
||||
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
|
||||
<SidebarMenuButton isActive={isFolderSelected} onClick={() => onSelect(item.path, item.kind)}>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
|
|
@ -1320,6 +1491,9 @@ function Tree({
|
|||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={onRenameTargetConsumed}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
|
|
@ -1371,7 +1545,7 @@ function Tree({
|
|||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<SidebarMenuButton isActive={isFolderSelected}>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
|
|
@ -1390,6 +1564,9 @@ function Tree({
|
|||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={onRenameTargetConsumed}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function TabBar<T>({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 self-stretch min-w-0',
|
||||
'rowboat-tabbar flex flex-1 self-stretch min-w-0',
|
||||
layout === 'scroll'
|
||||
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
: 'overflow-hidden'
|
||||
|
|
@ -57,7 +57,7 @@ export function TabBar<T>({
|
|||
type="button"
|
||||
onClick={() => onSwitchTab(tabId)}
|
||||
className={cn(
|
||||
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||
'rowboat-tab titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
|
||||
isActive
|
||||
? 'bg-background text-foreground'
|
||||
|
|
|
|||
24
apps/x/apps/renderer/src/components/terminal-output.tsx
Normal file
24
apps/x/apps/renderer/src/components/terminal-output.tsx
Normal file
|
|
@ -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) => (
|
||||
<React.Fragment key={lineIdx}>
|
||||
{lineIdx > 0 && '\n'}
|
||||
{line.spans.map((span, spanIdx) => {
|
||||
const css = spanStyleToCSS(span.style)
|
||||
return css ? (
|
||||
<span key={spanIdx} style={css}>{span.text}</span>
|
||||
) : (
|
||||
<React.Fragment key={spanIdx}>{span.text}</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,522 +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<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
|
||||
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
|
||||
if (schedule.type === 'once') {
|
||||
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
|
||||
}
|
||||
if (schedule.type === 'cron') {
|
||||
return { icon: 'timer', text: describeCron(schedule.expression) }
|
||||
}
|
||||
if (schedule.type === 'window') {
|
||||
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` }
|
||||
}
|
||||
return { icon: 'calendar', text: 'Scheduled' }
|
||||
}
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
|
||||
const [yaml, setYaml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Listen for the open event and seed modal state.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackModalDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.trackId || !d?.filePath) return
|
||||
setDetail(d)
|
||||
setYaml(d.initialYaml ?? '')
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void fetchFresh(d)
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
if (!yaml) return null
|
||||
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
|
||||
}, [yaml])
|
||||
|
||||
const trackId = track?.trackId ?? detail?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What to track', visible: true },
|
||||
{ key: 'when', label: 'When to run', visible: !!schedule },
|
||||
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule, eventMatchCriteria])
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IPC-backed mutations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
updates,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleToggleActive = useCallback(() => {
|
||||
void runUpdate({ active: !active })
|
||||
}, [active, runUpdate])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!detail || isRunning) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [detail, isRunning])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
yaml: rawDraft,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
setEditingRaw(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail, rawDraft])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
if (res?.success) {
|
||||
// Tell the editor to remove the node so Tiptap's next save doesn't
|
||||
// re-create the track block on disk.
|
||||
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
|
||||
setOpen(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!detail) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: {
|
||||
trackId: detail.trackId,
|
||||
filePath: detail.filePath,
|
||||
},
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [detail])
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<div className="track-modal-header">
|
||||
<div className="track-modal-header-left">
|
||||
<div className="track-modal-icon-wrap">
|
||||
<Radio size={16} />
|
||||
</div>
|
||||
<div className="track-modal-title-col">
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogTitle className="track-modal-title">
|
||||
{trackId || 'Track'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="track-modal-subtitle">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
|
||||
{scheduleSummary.text}
|
||||
{eventMatchCriteria && triggerType === 'scheduled' && (
|
||||
<span className="track-modal-subtitle-sep">· also event-driven</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-modal-header-actions">
|
||||
<label className="track-modal-toggle">
|
||||
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
|
||||
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="track-modal-tabs">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="track-modal-body">
|
||||
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>}
|
||||
|
||||
{activeTab === 'what' && (
|
||||
<div className="track-modal-prose">
|
||||
{instruction
|
||||
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-modal-empty">No instruction set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && schedule && (
|
||||
<div className="track-modal-when">
|
||||
<div className="track-modal-when-headline">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
|
||||
<span>{scheduleSummary.text}</span>
|
||||
</div>
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
|
||||
{schedule.type === 'cron' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'window' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
|
||||
<dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'once' && (
|
||||
<>
|
||||
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="track-modal-prose">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-modal-empty">No event matching set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-details">
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
{lastRunId && (<>
|
||||
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
|
||||
</>)}
|
||||
{lastRunSummary && (<>
|
||||
<dt>Summary</dt><dd>{lastRunSummary}</dd>
|
||||
</>)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced (raw YAML) — all tabs */}
|
||||
<div className="track-modal-advanced">
|
||||
<button
|
||||
className="track-modal-advanced-toggle"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) {
|
||||
setRawDraft(yaml)
|
||||
setEditingRaw(true)
|
||||
} else {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<Code2 size={12} />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="track-modal-raw-editor">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="track-modal-textarea"
|
||||
/>
|
||||
<div className="track-modal-raw-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRaw}
|
||||
disabled={saving || rawDraft.trim() === yaml.trim()}
|
||||
>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — on Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-danger-zone">
|
||||
{confirmingDelete ? (
|
||||
<div className="track-modal-confirm">
|
||||
<span>Delete this track and its generated content?</span>
|
||||
<div className="track-modal-confirm-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Yes, delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="track-modal-delete-btn"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete track block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="track-modal-error">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="track-modal-footer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditWithCopilot}
|
||||
disabled={saving}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || saving}
|
||||
className="track-modal-run-btn"
|
||||
>
|
||||
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
149
apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx
Normal file
149
apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
const TEXT_FALLBACK_MAX_BYTES = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
interface UnsupportedFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; sizeBytes: number; canShowAsText: boolean }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
function basename(path: string): string {
|
||||
const idx = path.lastIndexOf('/')
|
||||
return idx >= 0 ? path.slice(idx + 1) : path
|
||||
}
|
||||
|
||||
function extensionLabel(path: string): string {
|
||||
const name = basename(path)
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot < 0) return 'No extension'
|
||||
return name.slice(dot + 1).toUpperCase()
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function UnsupportedFileViewer({ path }: UnsupportedFileViewerProps) {
|
||||
const [state, setState] = useState<State>({ kind: 'loading' })
|
||||
const [textContent, setTextContent] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ kind: 'loading' })
|
||||
setTextContent(null)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const stat = await window.ipc.invoke('workspace:stat', { path })
|
||||
if (cancelled) return
|
||||
if (stat.kind !== 'file') {
|
||||
setState({ kind: 'error', message: 'Selected path is not a file.' })
|
||||
return
|
||||
}
|
||||
setState({
|
||||
kind: 'ready',
|
||||
sizeBytes: stat.size,
|
||||
canShowAsText: stat.size <= TEXT_FALLBACK_MAX_BYTES,
|
||||
})
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setState({ kind: 'error', message })
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [path])
|
||||
|
||||
async function loadAsText() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
setTextContent(result.data)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setTextContent(`Failed to read as text: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.kind === 'loading') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground">
|
||||
<FileIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Could not open</p>
|
||||
<p className="max-w-md text-xs">{state.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (textContent !== null) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{basename(path)} · plain text view</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTextContent(null)}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">{textContent}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileIcon className="size-10 text-muted-foreground" />
|
||||
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
|
||||
{basename(path)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{extensionLabel(path)} · {formatSize(state.sizeBytes)}
|
||||
</p>
|
||||
<p className="max-w-md text-xs">No in-app preview for this file type.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
{state.canShowAsText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadAsText()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
Show as plain text
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
apps/x/apps/renderer/src/components/video-file-viewer.tsx
Normal file
53
apps/x/apps/renderer/src/components/video-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileVideoIcon } from 'lucide-react'
|
||||
|
||||
interface VideoFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'ready' | 'error'
|
||||
|
||||
export function VideoFileViewer({ path }: VideoFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileVideoIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot play this video</p>
|
||||
<p className="max-w-md text-xs">
|
||||
The codec or container format isn't supported by Chromium (e.g. WMV, AVI, or some MKV files).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black">
|
||||
<video
|
||||
key={path}
|
||||
src={src}
|
||||
controls
|
||||
className="max-h-full max-w-full"
|
||||
onLoadedMetadata={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
|||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { extractConferenceLink } from '../lib/calendar-event'
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
|
|
@ -40,25 +41,6 @@ function getTimeRange(event: blocks.CalendarEvent): string {
|
|||
return `${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to conferenceLink if already set.
|
||||
*/
|
||||
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
// Check conferenceData.entryPoints for video entry
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
// Check hangoutLink (Google Meet shortcut)
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
// Fall back to conferenceLink if present
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface ResolvedEvent {
|
||||
event: blocks.CalendarEvent
|
||||
loaded: blocks.CalendarEvent | null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
|
||||
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
|
@ -11,17 +11,47 @@ function formatEmailDate(dateStr: string): string {
|
|||
try {
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
const now = new Date()
|
||||
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract just the name part from "Name <email>" format */
|
||||
function senderFirstName(from: string): string {
|
||||
const name = from.replace(/<.*>/, '').trim()
|
||||
return name.split(/\s+/)[0] || name
|
||||
function formatFullDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function extractName(from: string): string {
|
||||
const match = from.match(/^([^<]+)</)
|
||||
if (match) return match[1].trim()
|
||||
const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
|
||||
return username.replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function getInitial(from: string): string {
|
||||
const name = extractName(from)
|
||||
return (name[0] || '?').toUpperCase()
|
||||
}
|
||||
|
||||
const GMAIL_AVATAR_COLORS = [
|
||||
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
|
||||
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
|
||||
]
|
||||
|
||||
function avatarColor(from: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0
|
||||
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -30,7 +60,307 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Email Block ---
|
||||
// --- Shared: expanded email body used by both block types ---
|
||||
|
||||
function EmailExpandedBody({
|
||||
config,
|
||||
resolvedTheme,
|
||||
}: {
|
||||
config: blocks.EmailBlock
|
||||
resolvedTheme: string
|
||||
}) {
|
||||
const [draftBody, setDraftBody] = useState(config.draft_response || '')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDraftBody(config.draft_response || '')
|
||||
}, [config.draft_response])
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.style.height = 'auto'
|
||||
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
||||
}
|
||||
}, [draftBody])
|
||||
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
}
|
||||
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config, draftBody])
|
||||
|
||||
const copyDraft = useCallback(() => {
|
||||
navigator.clipboard.writeText(draftBody).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = draftBody
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}, [draftBody])
|
||||
|
||||
const gmailUrl = config.threadId
|
||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
|
||||
const initial = config.from ? getInitial(config.from) : '?'
|
||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
||||
const hasDraft = !!config.draft_response
|
||||
|
||||
return (
|
||||
<div className="email-gmail-expanded">
|
||||
{config.subject && (
|
||||
<div className="email-gmail-exp-subject">{config.subject}</div>
|
||||
)}
|
||||
|
||||
<div className="email-gmail-exp-meta">
|
||||
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
<div className="email-gmail-exp-meta-right">
|
||||
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
|
||||
<div className="email-gmail-exp-to-date">
|
||||
{config.to && <span>to {config.to}</span>}
|
||||
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="email-gmail-exp-body">{config.latest_email}</div>
|
||||
|
||||
{config.past_summary && (
|
||||
<div className="email-gmail-exp-history">
|
||||
<div className="email-gmail-exp-history-label">Earlier conversation</div>
|
||||
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasDraft && (
|
||||
<div className="email-gmail-reply-row">
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-gmail-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
Draft with Rowboat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDraft && (
|
||||
<div className="email-gmail-compose">
|
||||
<div className="email-gmail-compose-to">
|
||||
<span className="email-gmail-compose-to-label">Reply</span>
|
||||
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
|
||||
</div>
|
||||
<textarea
|
||||
key={resolvedTheme}
|
||||
ref={bodyRef}
|
||||
className="email-gmail-compose-body"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="email-gmail-compose-footer">
|
||||
<button
|
||||
className="email-gmail-btn email-gmail-btn-primary"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||
</button>
|
||||
<button
|
||||
className="email-gmail-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); copyDraft() }}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied!' : 'Copy draft'}
|
||||
</button>
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-gmail-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Multi-email inbox block (language-emails) ---
|
||||
|
||||
function EmailsBlockView({ node, deleteNode }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmailsBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
|
||||
} catch { /* fallback below */ }
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
|
||||
|
||||
if (!config || config.emails.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
|
||||
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
|
||||
<div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
|
||||
|
||||
{config.title && (
|
||||
<div className="email-inbox-title">{config.title}</div>
|
||||
)}
|
||||
|
||||
<div className="email-inbox-list">
|
||||
{config.emails.map((email, i) => {
|
||||
const isExpanded = expandedIndex === i
|
||||
const senderName = email.from ? extractName(email.from) : 'Unknown'
|
||||
const initial = email.from ? getInitial(email.from) : '?'
|
||||
const color = email.from ? avatarColor(email.from) : '#5f6368'
|
||||
const snippet = email.summary
|
||||
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
|
||||
|
||||
return (
|
||||
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
|
||||
{/* Collapsed row */}
|
||||
<div
|
||||
className="email-inbox-row-header"
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
|
||||
<div className="email-inbox-content">
|
||||
<div className="email-inbox-top-row">
|
||||
<span className="email-inbox-sender">{senderName}</span>
|
||||
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
|
||||
</div>
|
||||
<div className="email-inbox-bottom-row">
|
||||
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
|
||||
{snippet && (
|
||||
<span className="email-inbox-snippet">
|
||||
{email.subject ? ` — ${snippet}` : snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="email-inbox-expanded-wrap">
|
||||
<EmailExpandedBody
|
||||
config={email}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmailsBlockExtension = Node.create({
|
||||
name: 'emailsBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return { data: { default: '{}' } }
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'pre',
|
||||
priority: 61,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
if ((code.className || '').includes('language-emails')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmailsBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```emails\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// --- Single email block (language-email, backward compat) ---
|
||||
|
||||
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
|
|
@ -42,194 +372,57 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
|
||||
try {
|
||||
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const hasDraft = !!config?.draft_response
|
||||
const hasPastSummary = !!config?.past_summary
|
||||
} catch { /* fallback below */ }
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Local draft state for editing
|
||||
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||
const [emailExpanded, setEmailExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync draft from external changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||
setDraftBody(parsed.draft_response || '')
|
||||
} catch { /* ignore */ }
|
||||
}, [raw])
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.style.height = 'auto'
|
||||
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
||||
}
|
||||
}, [draftBody])
|
||||
|
||||
const commitDraft = useCallback((newBody: string) => {
|
||||
try {
|
||||
const current = JSON.parse(raw) as Record<string, unknown>
|
||||
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
|
||||
} catch { /* ignore */ }
|
||||
}, [raw, updateAttributes])
|
||||
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
if (!config) return
|
||||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
}
|
||||
prompt += `.\n\n`
|
||||
prompt += `**From:** ${config.from || 'Unknown'}\n`
|
||||
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) {
|
||||
prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
}
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config, draftBody])
|
||||
void updateAttributes // available for future per-email draft persistence
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-error">
|
||||
<Mail size={16} />
|
||||
<span>Invalid email block</span>
|
||||
</div>
|
||||
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const gmailUrl = config.threadId
|
||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
|
||||
// Build summary: use explicit summary, or auto-generate from sender + subject
|
||||
const summary = config.summary
|
||||
|| (config.from && config.subject
|
||||
? `${senderFirstName(config.from)} reached out about ${config.subject}`
|
||||
: config.subject || 'New email')
|
||||
const senderName = config.from ? extractName(config.from) : 'Unknown'
|
||||
const initial = config.from ? getInitial(config.from) : '?'
|
||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
||||
const snippet = config.summary
|
||||
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button>
|
||||
|
||||
{/* Header: Email badge */}
|
||||
<div className="email-block-badge">
|
||||
<Mail size={13} />
|
||||
Email
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="email-block-summary">{summary}</div>
|
||||
|
||||
{/* Expandable email details */}
|
||||
<button
|
||||
className="email-block-expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
|
||||
<div
|
||||
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{emailExpanded ? 'Hide email' : 'Show email'}
|
||||
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
|
||||
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
|
||||
</button>
|
||||
|
||||
{emailExpanded && (
|
||||
<div className="email-block-email-details">
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
<div className="email-block-sender-info">
|
||||
<div className="email-block-sender-row">
|
||||
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
<div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div>
|
||||
<div className="email-gmail-content">
|
||||
<div className="email-gmail-top-row">
|
||||
<span className="email-gmail-sender">{senderName}</span>
|
||||
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
|
||||
</div>
|
||||
<div className="email-gmail-bottom-row">
|
||||
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
|
||||
{snippet && <span className="email-gmail-snippet">{config.subject ? ` — ${snippet}` : snippet}</span>}
|
||||
</div>
|
||||
{hasPastSummary && (
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-context-label">Earlier conversation</div>
|
||||
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft section */}
|
||||
{hasDraft && (
|
||||
<div className="email-block-draft-section">
|
||||
<div className="email-block-draft-label">Draft reply</div>
|
||||
<textarea
|
||||
key={resolvedTheme}
|
||||
ref={bodyRef}
|
||||
className="email-draft-block-body-input"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onBlur={() => commitDraft(draftBody)}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="email-block-actions">
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={draftWithAssistant}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||
</button>
|
||||
{hasDraft && (
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(draftBody).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {
|
||||
// Fallback for Electron contexts where clipboard API may fail
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = draftBody
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied!' : 'Copy draft'}
|
||||
</button>
|
||||
)}
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={() => window.open(gmailUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<EmailExpandedBody
|
||||
config={config}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
|
|
@ -243,9 +436,7 @@ export const EmailBlockExtension = Node.create({
|
|||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: { default: '{}' },
|
||||
}
|
||||
return { data: { default: '{}' } }
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
|
|
@ -256,7 +447,7 @@ export const EmailBlockExtension = Node.create({
|
|||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
|
||||
if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { Tweet } from 'react-tweet'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function getEmbedUrl(provider: string, url: string): string | null {
|
||||
|
|
@ -24,6 +25,28 @@ function getEmbedUrl(provider: string, url: string): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
function extractTweetId(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const hostname = parsed.hostname
|
||||
.toLowerCase()
|
||||
.replace(/^www\./, '')
|
||||
.replace(/^mobile\./, '')
|
||||
if (hostname !== 'twitter.com' && hostname !== 'x.com') return null
|
||||
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if ((segments[i] === 'status' || segments[i] === 'statuses') && /^\d+$/.test(segments[i + 1])) {
|
||||
return segments[i + 1]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmbedBlock | null = null
|
||||
|
|
@ -45,6 +68,7 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
)
|
||||
}
|
||||
|
||||
const tweetId = extractTweetId(config.url)
|
||||
const embedUrl = getEmbedUrl(config.provider, config.url)
|
||||
|
||||
return (
|
||||
|
|
@ -57,7 +81,14 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{embedUrl ? (
|
||||
{config.provider === 'tweet' && tweetId ? (
|
||||
<div
|
||||
className="embed-block-tweet-shell"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Tweet id={tweetId} />
|
||||
</div>
|
||||
) : embedUrl ? (
|
||||
<div className="embed-block-iframe-container">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, Loader2 } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
// Detail shape for the open-track-modal window event. Defined here so the
|
||||
// consumer (TrackModal) can import it without a circular dependency.
|
||||
export type OpenTrackModalDetail = {
|
||||
trackId: string
|
||||
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
|
||||
filePath: string
|
||||
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
|
||||
initialYaml: string
|
||||
/** Invoked after a successful IPC delete so the editor can remove the node. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chip (display-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrackBlockView({ node, deleteNode, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(cleaned))
|
||||
} catch(error) { console.error('error', error); return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!trackId || !notePath) return
|
||||
const detail: OpenTrackModalDetail = {
|
||||
trackId,
|
||||
filePath: notePath,
|
||||
initialYaml: raw,
|
||||
onDeleted: () => deleteNode(),
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||
'rowboat:open-track-modal',
|
||||
{ detail },
|
||||
))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="track-block-chip-wrapper"
|
||||
data-type="track-block"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||
>
|
||||
{isRunning
|
||||
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
|
||||
: <Radio size={13} className="track-block-chip-icon" />}
|
||||
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||
{instruction && (
|
||||
<span className="track-block-chip-sep">·</span>
|
||||
)}
|
||||
{instruction && (
|
||||
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||
)}
|
||||
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-track')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TrackBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```track\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Track target markers — two Tiptap atom nodes that represent the open and
|
||||
* close HTML comment markers bracketing a track's output region on disk:
|
||||
*
|
||||
* <!--track-target:ID--> → TrackTargetOpenExtension
|
||||
* content in between → regular Tiptap nodes (paragraphs, lists,
|
||||
* custom blocks, whatever tiptap-markdown parses)
|
||||
* <!--/track-target:ID--> → TrackTargetCloseExtension
|
||||
*
|
||||
* The markers are *semantic boundaries*, not a UI container. Content between
|
||||
* them is real, editable document content — fully rendered by the existing
|
||||
* extension set and freely editable by the user. The backend's updateContent()
|
||||
* in fileops.ts still locates the region on disk by these comment markers.
|
||||
*
|
||||
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
|
||||
* regex replace, converting each comment into a placeholder div that these
|
||||
* extensions' parseHTML rules pick up. No content capture.
|
||||
*
|
||||
* Save path: both Tiptap's built-in markdown serializer
|
||||
* (`addStorage().markdown.serialize`) AND the app's custom serializer
|
||||
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
|
||||
* back out — they must stay in sync.
|
||||
*/
|
||||
|
||||
type MarkerVariant = 'open' | 'close'
|
||||
|
||||
function buildMarkerExtension(variant: MarkerVariant) {
|
||||
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
|
||||
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
|
||||
const commentFor = (id: string) =>
|
||||
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
|
||||
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
trackId: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${htmlType}"]`,
|
||||
getAttrs(el) {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return { trackId: el.getAttribute('data-track-id') ?? '' }
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': htmlType,
|
||||
'data-track-id': (node.attrs.trackId as string) ?? '',
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
|
||||
node: { attrs: { trackId: string } },
|
||||
) {
|
||||
state.write(commentFor(node.attrs.trackId ?? ''))
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled via preprocessTrackTargets → parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const TrackTargetOpenExtension = buildMarkerExtension('open')
|
||||
export const TrackTargetCloseExtension = buildMarkerExtension('close')
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { Node, mergeAttributes } from '@tiptap/react'
|
||||
import { InputRule, inputRules } from '@tiptap/pm/inputrules'
|
||||
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||
|
||||
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
|
||||
|
|
@ -88,13 +87,13 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
return [
|
||||
{
|
||||
tag: 'wiki-link[data-path]',
|
||||
getAttrs: (element) => ({
|
||||
getAttrs: (element: Element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a[data-type="wiki-link"]',
|
||||
getAttrs: (element) => ({
|
||||
getAttrs: (element: Element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
}),
|
||||
},
|
||||
|
|
@ -132,23 +131,23 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
addInputRules() {
|
||||
const onCreate = this.options.onCreate
|
||||
const rules = [
|
||||
new InputRule(wikiLinkInputRegex, (state, match, start, end) => {
|
||||
const rawPath = match[1]?.trim()
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||
if (state.selection.$from.parent.type.spec.code) return null
|
||||
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
||||
return [
|
||||
new InputRule({
|
||||
find: wikiLinkInputRegex,
|
||||
handler: ({ state, range, match }) => {
|
||||
const rawPath = match[1]?.trim()
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||
if (state.selection.$from.parent.type.spec.code) return null
|
||||
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
||||
|
||||
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||
const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath }))
|
||||
onCreate?.(finalPath)
|
||||
return tr
|
||||
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath }))
|
||||
onCreate?.(finalPath)
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
return [inputRules({ rules })]
|
||||
},
|
||||
})
|
||||
|
|
|
|||
72
apps/x/apps/renderer/src/hooks/use-bg-task-agent-status.ts
Normal file
72
apps/x/apps/renderer/src/hooks/use-bg-task-agent-status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { BackgroundTaskAgentEvent } from '@x/shared/dist/background-task.js';
|
||||
|
||||
export type BackgroundTaskAgentStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface BackgroundTaskAgentState {
|
||||
status: BackgroundTaskAgentStatus;
|
||||
runId?: string;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Module-level store — shared across all hook consumers, subscribed once.
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change.
|
||||
let store = new Map<string, BackgroundTaskAgentState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
function updateStore(fn: (prev: Map<string, BackgroundTaskAgentState>) => void) {
|
||||
store = new Map(store);
|
||||
fn(store);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function ensureSubscription() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
window.ipc.on('bg-task-agent:events', ((event: z.infer<typeof BackgroundTaskAgentEvent>) => {
|
||||
const key = event.slug;
|
||||
|
||||
if (event.type === 'background_task_agent_start') {
|
||||
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
|
||||
} else if (event.type === 'background_task_agent_complete') {
|
||||
updateStore(s => s.set(key, {
|
||||
status: event.error ? 'error' : 'done',
|
||||
runId: event.runId,
|
||||
summary: event.summary ?? null,
|
||||
error: event.error ?? null,
|
||||
}));
|
||||
// Auto-clear after 5 seconds
|
||||
setTimeout(() => {
|
||||
updateStore(s => s.delete(key));
|
||||
}, 5000);
|
||||
}
|
||||
}) as (event: z.infer<typeof BackgroundTaskAgentEvent>) => void);
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
ensureSubscription();
|
||||
listeners.add(onStoreChange);
|
||||
return () => { listeners.delete(onStoreChange); };
|
||||
}
|
||||
|
||||
function getSnapshot(): Map<string, BackgroundTaskAgentState> {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map of all bg-task agent run states, keyed by `slug`.
|
||||
*
|
||||
* Usage in the detail view:
|
||||
* const status = useBackgroundTaskAgentStatus();
|
||||
* const state = status.get(slug) ?? { status: 'idle' };
|
||||
*
|
||||
* Usage for a global indicator:
|
||||
* const status = useBackgroundTaskAgentStatus();
|
||||
* const anyRunning = [...status.values()].some(s => s.status === 'running');
|
||||
*/
|
||||
export function useBackgroundTaskAgentStatus(): Map<string, BackgroundTaskAgentState> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TrackEvent } from '@x/shared/dist/track-block.js';
|
||||
import { LiveNoteAgentEvent } from '@x/shared/dist/live-note.js';
|
||||
|
||||
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
export type LiveNoteAgentStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface TrackState {
|
||||
status: TrackRunStatus;
|
||||
export interface LiveNoteAgentState {
|
||||
status: LiveNoteAgentStatus;
|
||||
runId?: string;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Module-level store — shared across all hook consumers, subscribed once
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change
|
||||
let store = new Map<string, TrackState>();
|
||||
// Module-level store — shared across all hook consumers, subscribed once.
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change.
|
||||
let store = new Map<string, LiveNoteAgentState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
|
||||
function updateStore(fn: (prev: Map<string, LiveNoteAgentState>) => void) {
|
||||
store = new Map(store);
|
||||
fn(store);
|
||||
for (const listener of listeners) listener();
|
||||
|
|
@ -26,12 +26,12 @@ function updateStore(fn: (prev: Map<string, TrackState>) => void) {
|
|||
function ensureSubscription() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
|
||||
const key = `${event.trackId}:${event.filePath}`;
|
||||
window.ipc.on('live-note-agent:events', ((event: z.infer<typeof LiveNoteAgentEvent>) => {
|
||||
const key = event.filePath;
|
||||
|
||||
if (event.type === 'track_run_start') {
|
||||
if (event.type === 'live_note_agent_start') {
|
||||
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
|
||||
} else if (event.type === 'track_run_complete') {
|
||||
} else if (event.type === 'live_note_agent_complete') {
|
||||
updateStore(s => s.set(key, {
|
||||
status: event.error ? 'error' : 'done',
|
||||
runId: event.runId,
|
||||
|
|
@ -43,7 +43,7 @@ function ensureSubscription() {
|
|||
updateStore(s => s.delete(key));
|
||||
}, 5000);
|
||||
}
|
||||
}) as (event: z.infer<typeof TrackEvent>) => void);
|
||||
}) as (event: z.infer<typeof LiveNoteAgentEvent>) => void);
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
|
|
@ -52,21 +52,21 @@ function subscribe(onStoreChange: () => void): () => void {
|
|||
return () => { listeners.delete(onStoreChange); };
|
||||
}
|
||||
|
||||
function getSnapshot(): Map<string, TrackState> {
|
||||
function getSnapshot(): Map<string, LiveNoteAgentState> {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map of all track run states, keyed by "trackId:filePath".
|
||||
* Returns a Map of all live-note agent run states, keyed by `filePath`.
|
||||
*
|
||||
* Usage in a track block component:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
|
||||
* Usage in a panel:
|
||||
* const status = useLiveNoteAgentStatus();
|
||||
* const state = status.get(filePath) ?? { status: 'idle' };
|
||||
*
|
||||
* Usage for a global indicator:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
|
||||
* const status = useLiveNoteAgentStatus();
|
||||
* const anyRunning = [...status.values()].some(s => s.status === 'running');
|
||||
*/
|
||||
export function useTrackStatus(): Map<string, TrackState> {
|
||||
export function useLiveNoteAgentStatus(): Map<string, LiveNoteAgentState> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
124
apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts
Normal file
124
apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { LiveNote } from '@x/shared/dist/live-note.js'
|
||||
import { useLiveNoteAgentStatus, type LiveNoteAgentState } from './use-live-note-agent-status'
|
||||
|
||||
export interface UseLiveNoteForPathResult {
|
||||
/** Parsed `live:` block, or null when the note is passive. */
|
||||
live: LiveNote | null
|
||||
/** Knowledge-relative path (no leading "knowledge/"). Empty when no path is provided. */
|
||||
knowledgeRelPath: string
|
||||
/** Most recent run state from the agent bus. */
|
||||
agentState: LiveNoteAgentState | null
|
||||
/** Whether the agent is currently running. Convenience read off agentState. */
|
||||
isRunning: boolean
|
||||
/** Loading flag for the initial fetch. */
|
||||
loading: boolean
|
||||
/** Force a refetch — useful after a mutation. */
|
||||
refresh: () => Promise<void>
|
||||
/** Tick value that increments once a minute so callers can keep relative-time labels fresh. */
|
||||
tick: number
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string | null | undefined): string {
|
||||
if (!p) return ''
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
||||
function isSamePath(a: string, b: string | undefined): boolean {
|
||||
if (!b) return false
|
||||
return a === b.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive view of a single note's `live:` block.
|
||||
*
|
||||
* - Fetches `live-note:get` on mount and whenever the path changes.
|
||||
* - Subscribes to `live-note-agent:events` (via `useLiveNoteAgentStatus`) to
|
||||
* surface the running flag in real time.
|
||||
* - Listens to `workspace:didChange` so external edits to the file trigger a
|
||||
* refetch.
|
||||
* - Refetches one extra time when an agent run completes so callers see fresh
|
||||
* `lastRunAt` / `lastRunSummary` / `lastRunError` values.
|
||||
* - Ticks every minute so callers using `formatRelativeTime` get a fresh label
|
||||
* without the underlying data changing.
|
||||
*
|
||||
* `notePath` may be either knowledge-relative (`Digest.md`) or workspace-rooted
|
||||
* (`knowledge/Digest.md`); the hook normalises internally.
|
||||
*/
|
||||
export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult {
|
||||
const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null)
|
||||
const [live, setLive] = useState<LiveNote | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
const agentStatusMap = useLiveNoteAgentStatus()
|
||||
const agentState = knowledgeRelPath ? agentStatusMap.get(knowledgeRelPath) ?? null : null
|
||||
const isRunning = agentState?.status === 'running'
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!knowledgeRelPath) { setLive(null); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:get', { filePath: knowledgeRelPath })
|
||||
if (res.success) {
|
||||
setLive(res.live ?? null)
|
||||
}
|
||||
} catch {
|
||||
// Swallow — passive notes / missing files are fine; the next refresh retries.
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [knowledgeRelPath])
|
||||
|
||||
// Initial fetch + on path change.
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Refetch when the agent run completes (status flips to done/error) so
|
||||
// lastRunAt / lastRunError values picked up off disk are fresh.
|
||||
const agentStatus = agentState?.status
|
||||
useEffect(() => {
|
||||
if (agentStatus === 'done' || agentStatus === 'error') {
|
||||
void refresh()
|
||||
}
|
||||
}, [agentStatus, refresh])
|
||||
|
||||
// Refetch on external file changes — covers the case where the runner
|
||||
// patched lastRunSummary on the same file we're viewing.
|
||||
useEffect(() => {
|
||||
if (!knowledgeRelPath) return
|
||||
const fullPath = `knowledge/${knowledgeRelPath}`
|
||||
const cleanup = window.ipc.on('workspace:didChange', (event) => {
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (event.path === fullPath) void refresh()
|
||||
break
|
||||
case 'moved':
|
||||
if (event.from === fullPath || event.to === fullPath) void refresh()
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (event.paths?.some(p => isSamePath(knowledgeRelPath, p))) void refresh()
|
||||
break
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [knowledgeRelPath, refresh])
|
||||
|
||||
// Minute-by-minute tick to keep relative-time labels fresh.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(t => t + 1), 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
live,
|
||||
knowledgeRelPath,
|
||||
agentState,
|
||||
isRunning,
|
||||
loading,
|
||||
refresh,
|
||||
tick,
|
||||
}
|
||||
}
|
||||
|
|
@ -58,15 +58,29 @@ export function useAnalyticsIdentity() {
|
|||
// Listen for OAuth connect/disconnect events to update identity
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (!event.success) return
|
||||
|
||||
// If Rowboat provider connected, identify user
|
||||
if (event.provider === 'rowboat' && event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
posthog.people.set({ signed_in: true })
|
||||
if (event.provider !== 'rowboat') {
|
||||
// Other providers: just toggle the connection flag
|
||||
if (event.success) {
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
// Rowboat sign-in
|
||||
if (event.success) {
|
||||
if (event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
}
|
||||
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
||||
posthog.capture('user_signed_in')
|
||||
return
|
||||
}
|
||||
|
||||
// Rowboat sign-out — flip flags, capture, and reset distinct_id so
|
||||
// future events on this device don't get attributed to the prior user.
|
||||
posthog.people.set({ signed_in: false, rowboat_connected: false })
|
||||
posthog.capture('user_signed_out')
|
||||
posthog.reset()
|
||||
})
|
||||
|
||||
return cleanup
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface BillingInfo {
|
||||
userEmail: string | null
|
||||
userId: string | null
|
||||
subscriptionPlan: string | null
|
||||
subscriptionStatus: string | null
|
||||
trialExpiresAt: string | null
|
||||
sanctionedCredits: number
|
||||
availableCredits: number
|
||||
}
|
||||
import type { BillingInfo } from '@x/shared/dist/billing.js'
|
||||
|
||||
export function useBilling(isRowboatConnected: boolean) {
|
||||
const [billing, setBilling] = useState<BillingInfo | null>(null)
|
||||
|
|
|
|||
|
|
@ -38,16 +38,21 @@ export function useConnectors(active: boolean) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
||||
// and never flipped — the IPC that used to set them is gone. The setters
|
||||
// remain so the legacy Composio-Gmail handlers below still type-check,
|
||||
// but those handlers are no longer reachable in the UI (the gating
|
||||
// condition `useComposioForGoogle` stays false).
|
||||
// TODO follow-up: drop these flags entirely and prune the dead UI branches
|
||||
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailLoading, setGmailLoading] = useState(false)
|
||||
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 [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
// Load available providers on mount
|
||||
|
|
@ -67,28 +72,7 @@ export function useConnectors(active: boolean) {
|
|||
loadProviders()
|
||||
}, [])
|
||||
|
||||
// Re-check composio-for-google flags when active
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
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)
|
||||
}
|
||||
}
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [active])
|
||||
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
|
|
@ -346,13 +330,22 @@ export function useConnectors(active: boolean) {
|
|||
|
||||
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. Main process detects
|
||||
// signed-in via isSignedIn() when oauth:connect arrives without creds.
|
||||
// Falls back to the BYOK modal for not-signed-in users.
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdDescription(undefined)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
}, [startConnect, providerStates])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
@ -361,6 +354,25 @@ export function useConnectors(active: boolean) {
|
|||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
// Reconnect flow used by the "Reconnect" button. Mirrors handleConnect's
|
||||
// rowboat-vs-BYOK branching for Google so signed-in users don't get the
|
||||
// client-ID modal — they just re-run the managed-credentials browser flow.
|
||||
const handleReconnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
await startConnect(provider)
|
||||
}, [startConnect, providerStates])
|
||||
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -485,19 +497,6 @@ export function useConnectors(active: boolean) {
|
|||
toast.success(`Connected to ${displayName}`)
|
||||
}
|
||||
|
||||
if (provider === 'rowboat') {
|
||||
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 (err) {
|
||||
console.error('Failed to re-check composio flags:', err)
|
||||
}
|
||||
}
|
||||
|
||||
refreshAllStatuses()
|
||||
}
|
||||
})
|
||||
|
|
@ -554,6 +553,7 @@ export function useConnectors(active: boolean) {
|
|||
providerStatus,
|
||||
hasProviderError,
|
||||
handleConnect,
|
||||
handleReconnect,
|
||||
handleDisconnect,
|
||||
startConnect,
|
||||
|
||||
|
|
|
|||
15
apps/x/apps/renderer/src/lib/calendar-event.ts
Normal file
15
apps/x/apps/renderer/src/lib/calendar-event.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to a top-level conferenceLink if present.
|
||||
*/
|
||||
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export interface ToolCall {
|
|||
name: string
|
||||
input: ToolUIPart['input']
|
||||
result?: ToolUIPart['output']
|
||||
streamingOutput?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'error'
|
||||
timestamp: number
|
||||
}
|
||||
|
|
@ -586,6 +587,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat
|
|||
return null
|
||||
}
|
||||
|
||||
export type ToolGroup = {
|
||||
type: 'tool-group'
|
||||
items: ToolCall[]
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type GroupedConversationItem = ConversationItem | ToolGroup
|
||||
|
||||
export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
||||
'type' in item && (item as ToolGroup).type === 'tool-group'
|
||||
|
||||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||
if (!isToolCall(item)) return false
|
||||
if (getWebSearchCardData(item)) return false
|
||||
if (getComposioConnectCardData(item)) return false
|
||||
if (getAppActionCardData(item)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const groupConversationItems = (
|
||||
items: ConversationItem[],
|
||||
hasPermissionRequest: (id: string) => boolean
|
||||
): GroupedConversationItem[] => {
|
||||
const result: GroupedConversationItem[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < items.length) {
|
||||
const item = items[i]
|
||||
if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) {
|
||||
const group: ToolCall[] = [item]
|
||||
i++
|
||||
while (
|
||||
i < items.length &&
|
||||
isPlainToolCall(items[i] as ConversationItem) &&
|
||||
!hasPermissionRequest((items[i] as ToolCall).id)
|
||||
) {
|
||||
group.push(items[i] as ToolCall)
|
||||
i++
|
||||
}
|
||||
if (group.length === 1) {
|
||||
result.push(group[0])
|
||||
} else {
|
||||
result.push({ type: 'tool-group', items: group, groupId: group[0].id })
|
||||
}
|
||||
} else {
|
||||
result.push(item)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const getToolGroupSummary = (tools: ToolCall[]): string => {
|
||||
const seen = new Set<string>()
|
||||
const names: string[] = []
|
||||
for (const tool of tools) {
|
||||
const name = getToolDisplayName(tool)
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
return names.join(' · ')
|
||||
}
|
||||
|
||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
56
apps/x/apps/renderer/src/lib/file-types.ts
Normal file
56
apps/x/apps/renderer/src/lib/file-types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Single source of truth for which file types the knowledge viewer renders.
|
||||
*
|
||||
* Both the App.tsx loader-skip check and the render-switch consume this so
|
||||
* adding a new extension is a one-place edit. The persistent-viewer-cache
|
||||
* also uses it to decide what to keep mounted.
|
||||
*/
|
||||
|
||||
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf'
|
||||
|
||||
const VIEWER_BY_EXT: Record<string, ViewerType> = {
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
png: 'image',
|
||||
jpg: 'image',
|
||||
jpeg: 'image',
|
||||
webp: 'image',
|
||||
gif: 'image',
|
||||
svg: 'image',
|
||||
avif: 'image',
|
||||
bmp: 'image',
|
||||
ico: 'image',
|
||||
mp4: 'video',
|
||||
mov: 'video',
|
||||
webm: 'video',
|
||||
m4v: 'video',
|
||||
mp3: 'audio',
|
||||
wav: 'audio',
|
||||
m4a: 'audio',
|
||||
ogg: 'audio',
|
||||
flac: 'audio',
|
||||
aac: 'audio',
|
||||
pdf: 'pdf',
|
||||
}
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
const lower = path.toLowerCase()
|
||||
const dot = lower.lastIndexOf('.')
|
||||
return dot >= 0 ? lower.slice(dot + 1) : ''
|
||||
}
|
||||
|
||||
/** Returns the viewer type for a path, or null if no media viewer handles it. */
|
||||
export function getViewerType(path: string): ViewerType | null {
|
||||
return VIEWER_BY_EXT[extensionOf(path)] ?? null
|
||||
}
|
||||
|
||||
/** True if the path is rendered by one of the dedicated media viewers. */
|
||||
export function isMediaPath(path: string): boolean {
|
||||
return getViewerType(path) !== null
|
||||
}
|
||||
|
||||
/** True if the viewer for this path participates in the persistent mount cache. */
|
||||
export function isCacheableViewerPath(path: string): boolean {
|
||||
const t = getViewerType(path)
|
||||
return t === 'html' || t === 'pdf'
|
||||
}
|
||||
|
|
@ -133,9 +133,19 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
|
|||
}
|
||||
|
||||
/**
|
||||
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list values are string[].
|
||||
* Skips `---` delimiters and blank lines.
|
||||
* Keys that hold structured (nested object/array-of-object) data and must NOT
|
||||
* be mangled by the flat-string FrontmatterProperties UI. These pass through
|
||||
* unchanged on a round-trip — never exposed as editable fields, never
|
||||
* re-emitted by buildFrontmatter (callers must splice them back from the
|
||||
* original raw if they want to preserve them on save — see the helpers below).
|
||||
*/
|
||||
const STRUCTURED_KEYS = new Set(['live'])
|
||||
|
||||
/**
|
||||
* Extract editable top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list-of-string
|
||||
* values are string[]. Structured keys (e.g. `live:`) and any nested-object
|
||||
* shapes are filtered out — they are not editable via this surface.
|
||||
*/
|
||||
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {}
|
||||
|
|
@ -143,10 +153,12 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
|
|||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
let pendingNested = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---' || line.trim() === '') {
|
||||
currentKey = null
|
||||
pendingNested = false
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -155,39 +167,61 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
|
|||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
pendingNested = false
|
||||
if (STRUCTURED_KEYS.has(key)) {
|
||||
currentKey = null
|
||||
continue
|
||||
}
|
||||
if (value) {
|
||||
result[key] = value
|
||||
currentKey = null
|
||||
} else {
|
||||
// List will follow
|
||||
currentKey = key
|
||||
result[key] = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item under current key
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim())
|
||||
}
|
||||
if (!currentKey) continue
|
||||
|
||||
// List item under current key.
|
||||
const itemMatch = line.match(/^\s+-\s+(.*)$/)
|
||||
if (itemMatch) {
|
||||
const item = itemMatch[1].trim()
|
||||
// If the list-item line itself contains a `key: value` pair, this is a
|
||||
// nested-object shape (e.g. `- startTime: "09:00"` under a windows list). We
|
||||
// can't represent that as a flat string array — drop the whole key.
|
||||
if (/^\w[\w\s]*\w?:\s*\S/.test(item)) {
|
||||
delete result[currentKey]
|
||||
currentKey = null
|
||||
pendingNested = true
|
||||
continue
|
||||
}
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) arr.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Indented continuation of a nested object — keep dropping its parent.
|
||||
if (pendingNested && /^\s/.test(line)) continue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
|
||||
* Returns null if no non-empty fields remain.
|
||||
* Convert a Record of editable frontmatter fields back to a raw YAML
|
||||
* frontmatter string. If `preserveRaw` is provided, structured keys (e.g.
|
||||
* `live:`) are spliced back from the original raw byte-for-byte, so
|
||||
* round-trips through the FrontmatterProperties UI never lose them.
|
||||
*/
|
||||
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||
export function buildFrontmatter(
|
||||
fields: Record<string, string | string[]>,
|
||||
preserveRaw: string | null = null,
|
||||
): string | null {
|
||||
const lines: string[] = []
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (STRUCTURED_KEYS.has(key)) continue
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue
|
||||
lines.push(`${key}:`)
|
||||
|
|
@ -200,8 +234,55 @@ export function buildFrontmatter(fields: Record<string, string | string[]>): str
|
|||
lines.push(`${key}: ${trimmed}`)
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
return `---\n${lines.join('\n')}\n---`
|
||||
|
||||
// Splice preserved structured-key blocks (e.g. live:) back from preserveRaw.
|
||||
const preservedBlocks: string[] = []
|
||||
if (preserveRaw) {
|
||||
for (const key of STRUCTURED_KEYS) {
|
||||
const block = extractTopLevelBlock(preserveRaw, key)
|
||||
if (block) preservedBlocks.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0 && preservedBlocks.length === 0) return null
|
||||
const allLines = [...lines, ...preservedBlocks.flatMap(b => b.split('\n'))]
|
||||
return `---\n${allLines.join('\n')}\n---`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the byte-for-byte line block for a top-level key in raw frontmatter,
|
||||
* including its nested children (any indented lines that follow), or null if
|
||||
* the key is absent. Used to round-trip structured keys safely.
|
||||
*/
|
||||
function extractTopLevelBlock(raw: string, key: string): string | null {
|
||||
const lines = raw.split('\n')
|
||||
let start = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '---') continue
|
||||
const m = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
|
||||
if (m && m[1] === key) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (start === -1) return null
|
||||
let end = start
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '---') break
|
||||
if (/^\s/.test(line)) {
|
||||
end = i
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
// blank line — end of this top-level block
|
||||
break
|
||||
}
|
||||
// another top-level key — stop
|
||||
break
|
||||
}
|
||||
return lines.slice(start, end + 1).join('\n')
|
||||
}
|
||||
|
||||
/** Map known tag values → category for legacy flat-list frontmatter. */
|
||||
|
|
|
|||
25
apps/x/apps/renderer/src/lib/relative-time.ts
Normal file
25
apps/x/apps/renderer/src/lib/relative-time.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Compact relative-time formatter — "just now", "5 m", "3 h", "2 d", "4 w",
|
||||
* "5 m" (months). Used by the chat sidebar's run list and the live-note pill.
|
||||
*
|
||||
* Returns an empty string for invalid timestamps so callers can fall back to
|
||||
* a default label.
|
||||
*/
|
||||
export function formatRelativeTime(ts: string): string {
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return ""
|
||||
const now = Date.now()
|
||||
const diffMs = Math.max(0, now - date.getTime())
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
|
||||
if (diffMinutes < 1) return "just now"
|
||||
if (diffMinutes < 60) return `${diffMinutes} m`
|
||||
if (diffHours < 24) return `${diffHours} h`
|
||||
if (diffDays < 7) return `${diffDays} d`
|
||||
if (diffWeeks < 4) return `${diffWeeks} w`
|
||||
return `${Math.max(1, diffMonths)} m`
|
||||
}
|
||||
138
apps/x/apps/renderer/src/lib/run-to-conversation.ts
Normal file
138
apps/x/apps/renderer/src/lib/run-to-conversation.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type z from 'zod'
|
||||
import type { RunEvent } from '@x/shared/dist/runs.js'
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
normalizeToolInput,
|
||||
} from './chat-conversation'
|
||||
|
||||
type RunLog = z.infer<typeof RunEvent>[]
|
||||
|
||||
/**
|
||||
* Convert a closed Run.log into a flat list of ConversationItems suitable
|
||||
* for read-only playback. Adapted from App.tsx's live-streaming converter
|
||||
* (lines ~1731-1843) but trimmed for static history:
|
||||
*
|
||||
* - drops llm-stream-event (reasoning lands in the final message)
|
||||
* - drops run-processing-* / start / spawn-subflow (lifecycle, not content)
|
||||
* - drops system/tool-role messages (only user + assistant surface)
|
||||
* - drops permission/ask-human (live-only flows)
|
||||
*/
|
||||
export function runLogToConversation(log: RunLog): ConversationItem[] {
|
||||
const items: ConversationItem[] = []
|
||||
const toolCallMap = new Map<string, ToolCall>()
|
||||
|
||||
for (const event of log) {
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') break
|
||||
|
||||
let textContent = ''
|
||||
let msgAttachments: ChatMessage['attachments']
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
const parts = msg.content as Array<{
|
||||
type: string
|
||||
text?: string
|
||||
path?: string
|
||||
filename?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
arguments?: unknown
|
||||
}>
|
||||
|
||||
textContent = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
|
||||
const attachmentParts = parts.filter((p) => p.type === 'attachment' && p.path)
|
||||
if (attachmentParts.length > 0) {
|
||||
msgAttachments = attachmentParts.map((p) => ({
|
||||
path: p.path!,
|
||||
filename: p.filename || p.path!.split('/').pop() || p.path!,
|
||||
mimeType: p.mimeType || 'application/octet-stream',
|
||||
size: p.size,
|
||||
}))
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
for (const part of parts) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: normalizeToolInput(part.arguments as ToolCall['input']),
|
||||
status: 'pending',
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
}
|
||||
toolCallMap.set(toolCall.id, toolCall)
|
||||
items.push(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (textContent || msgAttachments) {
|
||||
items.push({
|
||||
id: event.messageId,
|
||||
role: msg.role,
|
||||
content: textContent,
|
||||
attachments: msgAttachments,
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-invocation': {
|
||||
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existing) {
|
||||
existing.input = normalizeToolInput(event.input)
|
||||
existing.status = 'running'
|
||||
} else {
|
||||
const toolCall: ToolCall = {
|
||||
id: event.toolCallId || `tool-${items.length}`,
|
||||
name: event.toolName,
|
||||
input: normalizeToolInput(event.input),
|
||||
status: 'running',
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
}
|
||||
if (event.toolCallId) toolCallMap.set(toolCall.id, toolCall)
|
||||
items.push(toolCall)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-result': {
|
||||
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existing) {
|
||||
existing.result = event.result
|
||||
existing.status = 'completed'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
items.push({
|
||||
id: `error-${items.length}`,
|
||||
kind: 'error',
|
||||
message: event.error,
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Everything else is lifecycle/streaming — not part of the rendered transcript.
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
319
apps/x/apps/renderer/src/lib/terminal-output.ts
Normal file
319
apps/x/apps/renderer/src/lib/terminal-output.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* Terminal output processor that handles ANSI escape sequences, carriage returns,
|
||||
* and other terminal control characters to produce styled, terminal-like output.
|
||||
*/
|
||||
|
||||
export interface StyledSpan {
|
||||
text: string
|
||||
style: SpanStyle
|
||||
}
|
||||
|
||||
export interface SpanStyle {
|
||||
bold?: boolean
|
||||
dim?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
fg?: string
|
||||
bg?: string
|
||||
}
|
||||
|
||||
export interface TerminalLine {
|
||||
spans: StyledSpan[]
|
||||
}
|
||||
|
||||
const ANSI_COLORS_16: Record<number, string> = {
|
||||
30: '#4e4e4e', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b',
|
||||
34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#dcdfe4',
|
||||
90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#e5c07b',
|
||||
94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff',
|
||||
}
|
||||
|
||||
const ANSI_BG_COLORS_16: Record<number, string> = {
|
||||
40: '#4e4e4e', 41: '#e06c75', 42: '#98c379', 43: '#e5c07b',
|
||||
44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#dcdfe4',
|
||||
100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#e5c07b',
|
||||
104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff',
|
||||
}
|
||||
|
||||
function color256(n: number): string {
|
||||
if (n < 8) return ANSI_COLORS_16[30 + n] ?? '#dcdfe4'
|
||||
if (n < 16) return ANSI_COLORS_16[90 + (n - 8)] ?? '#dcdfe4'
|
||||
if (n < 232) {
|
||||
const idx = n - 16
|
||||
const r = Math.floor(idx / 36)
|
||||
const g = Math.floor((idx % 36) / 6)
|
||||
const b = idx % 6
|
||||
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
const level = 8 + (n - 232) * 10
|
||||
const hex = level.toString(16).padStart(2, '0')
|
||||
return `#${hex}${hex}${hex}`
|
||||
}
|
||||
|
||||
function parseSGR(params: number[], style: SpanStyle): SpanStyle {
|
||||
const s = { ...style }
|
||||
let i = 0
|
||||
while (i < params.length) {
|
||||
const p = params[i]
|
||||
if (p === 0) {
|
||||
delete s.bold
|
||||
delete s.dim
|
||||
delete s.italic
|
||||
delete s.underline
|
||||
delete s.strikethrough
|
||||
delete s.fg
|
||||
delete s.bg
|
||||
} else if (p === 1) s.bold = true
|
||||
else if (p === 2) s.dim = true
|
||||
else if (p === 3) s.italic = true
|
||||
else if (p === 4) s.underline = true
|
||||
else if (p === 9) s.strikethrough = true
|
||||
else if (p === 22) {
|
||||
delete s.bold
|
||||
delete s.dim
|
||||
} else if (p === 23) delete s.italic
|
||||
else if (p === 24) delete s.underline
|
||||
else if (p === 29) delete s.strikethrough
|
||||
else if (p >= 30 && p <= 37) s.fg = ANSI_COLORS_16[p]
|
||||
else if (p === 38) {
|
||||
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
|
||||
s.fg = color256(params[i + 2])
|
||||
i += 2
|
||||
} else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
|
||||
const r = params[i + 2]
|
||||
const g = params[i + 3]
|
||||
const b = params[i + 4]
|
||||
s.fg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
i += 4
|
||||
}
|
||||
} else if (p === 39) delete s.fg
|
||||
else if (p >= 40 && p <= 47) s.bg = ANSI_BG_COLORS_16[p]
|
||||
else if (p === 48) {
|
||||
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
|
||||
s.bg = color256(params[i + 2])
|
||||
i += 2
|
||||
} else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
|
||||
const r = params[i + 2]
|
||||
const g = params[i + 3]
|
||||
const b = params[i + 4]
|
||||
s.bg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
i += 4
|
||||
}
|
||||
} else if (p === 49) delete s.bg
|
||||
else if (p >= 90 && p <= 97) s.fg = ANSI_COLORS_16[p]
|
||||
else if (p >= 100 && p <= 107) s.bg = ANSI_BG_COLORS_16[p]
|
||||
i++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
export function processTerminalOutput(raw: string): TerminalLine[] {
|
||||
type Cell = { char: string; style: SpanStyle }
|
||||
const lines: Cell[][] = [[]]
|
||||
let cursorRow = 0
|
||||
let cursorCol = 0
|
||||
let currentStyle: SpanStyle = {}
|
||||
|
||||
function ensureRow(row: number) {
|
||||
while (lines.length <= row) lines.push([])
|
||||
}
|
||||
|
||||
function ensureCol(row: number, col: number) {
|
||||
ensureRow(row)
|
||||
const line = lines[row]
|
||||
while (line.length <= col) line.push({ char: ' ', style: {} })
|
||||
}
|
||||
|
||||
let i = 0
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i]
|
||||
|
||||
if (ch === '\x1b' && i + 1 < raw.length) {
|
||||
const next = raw[i + 1]
|
||||
|
||||
if (next === '[') {
|
||||
i += 2
|
||||
let paramStr = ''
|
||||
while (i < raw.length && raw[i] >= '\x20' && raw[i] <= '\x3f') {
|
||||
paramStr += raw[i]
|
||||
i++
|
||||
}
|
||||
const finalByte = i < raw.length ? raw[i] : ''
|
||||
i++
|
||||
|
||||
const params = paramStr.length > 0
|
||||
? paramStr.split(';').map(s => parseInt(s, 10) || 0)
|
||||
: [0]
|
||||
|
||||
switch (finalByte) {
|
||||
case 'm':
|
||||
currentStyle = parseSGR(params, currentStyle)
|
||||
break
|
||||
case 'A':
|
||||
cursorRow = Math.max(0, cursorRow - (params[0] || 1))
|
||||
break
|
||||
case 'B':
|
||||
cursorRow += (params[0] || 1)
|
||||
ensureRow(cursorRow)
|
||||
break
|
||||
case 'C':
|
||||
cursorCol += (params[0] || 1)
|
||||
break
|
||||
case 'D':
|
||||
cursorCol = Math.max(0, cursorCol - (params[0] || 1))
|
||||
break
|
||||
case 'G':
|
||||
cursorCol = Math.max(0, (params[0] || 1) - 1)
|
||||
break
|
||||
case 'H':
|
||||
case 'f':
|
||||
cursorRow = Math.max(0, (params[0] || 1) - 1)
|
||||
cursorCol = Math.max(0, (params[1] || 1) - 1)
|
||||
ensureRow(cursorRow)
|
||||
break
|
||||
case 'J': {
|
||||
const mode = params[0] || 0
|
||||
if (mode === 2 || mode === 3) {
|
||||
lines.length = 0
|
||||
lines.push([])
|
||||
cursorRow = 0
|
||||
cursorCol = 0
|
||||
} else if (mode === 0) {
|
||||
ensureRow(cursorRow)
|
||||
lines[cursorRow].length = cursorCol
|
||||
for (let r = cursorRow + 1; r < lines.length; r++) lines[r] = []
|
||||
} else if (mode === 1) {
|
||||
for (let r = 0; r < cursorRow; r++) lines[r] = []
|
||||
ensureCol(cursorRow, cursorCol)
|
||||
for (let c = 0; c <= cursorCol; c++) lines[cursorRow][c] = { char: ' ', style: {} }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'K': {
|
||||
const mode = params[0] || 0
|
||||
ensureRow(cursorRow)
|
||||
const line = lines[cursorRow]
|
||||
if (mode === 0) {
|
||||
line.length = cursorCol
|
||||
} else if (mode === 1) {
|
||||
ensureCol(cursorRow, cursorCol)
|
||||
for (let c = 0; c <= cursorCol; c++) line[c] = { char: ' ', style: {} }
|
||||
} else if (mode === 2) {
|
||||
lines[cursorRow] = []
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === ']') {
|
||||
i += 2
|
||||
while (i < raw.length && raw[i] !== '\x07' && !(raw[i] === '\x1b' && raw[i + 1] === '\\')) {
|
||||
i++
|
||||
}
|
||||
if (i < raw.length && raw[i] === '\x07') i++
|
||||
else if (i < raw.length) i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\r') {
|
||||
cursorCol = 0
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\n') {
|
||||
cursorRow++
|
||||
cursorCol = 0
|
||||
ensureRow(cursorRow)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\b') {
|
||||
cursorCol = Math.max(0, cursorCol - 1)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\t') {
|
||||
const nextTabStop = (Math.floor(cursorCol / 8) + 1) * 8
|
||||
while (cursorCol < nextTabStop) {
|
||||
ensureCol(cursorRow, cursorCol)
|
||||
lines[cursorRow][cursorCol] = { char: ' ', style: { ...currentStyle } }
|
||||
cursorCol++
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch.charCodeAt(0) < 32) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
ensureCol(cursorRow, cursorCol)
|
||||
lines[cursorRow][cursorCol] = { char: ch, style: { ...currentStyle } }
|
||||
cursorCol++
|
||||
i++
|
||||
}
|
||||
|
||||
return lines.map(cells => {
|
||||
const spans: StyledSpan[] = []
|
||||
if (cells.length === 0) return { spans: [{ text: '', style: {} }] }
|
||||
|
||||
let end = cells.length
|
||||
while (end > 0 && cells[end - 1].char === ' ' && Object.keys(cells[end - 1].style).length === 0) {
|
||||
end--
|
||||
}
|
||||
|
||||
let currentSpan: StyledSpan | null = null
|
||||
for (let c = 0; c < end; c++) {
|
||||
const cell = cells[c]
|
||||
const sameStyle = currentSpan && styleEquals(currentSpan.style, cell.style)
|
||||
if (sameStyle && currentSpan) {
|
||||
currentSpan.text += cell.char
|
||||
} else {
|
||||
if (currentSpan) spans.push(currentSpan)
|
||||
currentSpan = { text: cell.char, style: { ...cell.style } }
|
||||
}
|
||||
}
|
||||
if (currentSpan) spans.push(currentSpan)
|
||||
if (spans.length === 0) spans.push({ text: '', style: {} })
|
||||
return { spans }
|
||||
})
|
||||
}
|
||||
|
||||
function styleEquals(a: SpanStyle, b: SpanStyle): boolean {
|
||||
return a.bold === b.bold
|
||||
&& a.dim === b.dim
|
||||
&& a.italic === b.italic
|
||||
&& a.underline === b.underline
|
||||
&& a.strikethrough === b.strikethrough
|
||||
&& a.fg === b.fg
|
||||
&& a.bg === b.bg
|
||||
}
|
||||
|
||||
export function spanStyleToCSS(style: SpanStyle): React.CSSProperties | undefined {
|
||||
if (Object.keys(style).length === 0) return undefined
|
||||
const css: React.CSSProperties = {}
|
||||
if (style.fg) css.color = style.fg
|
||||
if (style.bg) css.backgroundColor = style.bg
|
||||
if (style.bold) css.fontWeight = 'bold'
|
||||
if (style.dim) css.opacity = 0.6
|
||||
if (style.italic) css.fontStyle = 'italic'
|
||||
if (style.underline) css.textDecoration = 'underline'
|
||||
if (style.strikethrough) {
|
||||
css.textDecoration = css.textDecoration ? `${css.textDecoration} line-through` : 'line-through'
|
||||
}
|
||||
return Object.keys(css).length > 0 ? css : undefined
|
||||
}
|
||||
|
|
@ -2,20 +2,45 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import posthog from 'posthog-js'
|
||||
import { PostHogProvider } from 'posthog-js/react'
|
||||
import { ThemeProvider } from '@/contexts/theme-context'
|
||||
|
||||
const options = {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2025-11-30',
|
||||
} as const
|
||||
// Fetch the stable installation ID from main so renderer + main share one
|
||||
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
||||
// if the IPC call fails (rare — main is always up before renderer).
|
||||
async function bootstrap() {
|
||||
let installationId: string | undefined
|
||||
let apiUrl: string | undefined
|
||||
try {
|
||||
const result = await window.ipc.invoke('analytics:bootstrap', null)
|
||||
installationId = result.installationId
|
||||
apiUrl = result.apiUrl
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to bootstrap from main:', err)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
const options = {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2025-11-30',
|
||||
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
|
||||
} as const
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Tag the active person record with api_url so anonymous users are also
|
||||
// segmentable by environment.
|
||||
if (apiUrl) {
|
||||
posthog.people.set({ api_url: apiUrl })
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
|
||||
|
||||
/* Tiptap Editor Styles */
|
||||
|
||||
.tiptap-editor {
|
||||
|
|
@ -654,155 +656,11 @@
|
|||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
/* =============================================================
|
||||
Track Block — inline chip (display-only)
|
||||
The chip just opens a modal (TrackModal). All mutations live in the
|
||||
modal and go through IPC, so the editor never writes track state.
|
||||
(Track inline chip and target-marker styles removed — tracks now
|
||||
live entirely in the note's frontmatter and are managed via the
|
||||
right-side track sidebar.)
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
|
||||
--track-accent: #64748b; /* default: manual/slate */
|
||||
margin: 4px 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
|
||||
border-left: 3px solid var(--track-accent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-running {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
|
||||
animation: track-chip-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes track-chip-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--track-accent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||
font-weight: 600;
|
||||
color: var(--track-accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track target markers — thin visual bookends around a track's
|
||||
output region. The content BETWEEN these markers is normal,
|
||||
editable document content (rendered by the existing extensions).
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 14px 0 6px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
|
||||
content: 'track: ' attr(data-track-id);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 8px;
|
||||
padding: 0 6px;
|
||||
background: var(--background, #fff);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
|
||||
height: 1px;
|
||||
margin: 6px 0 14px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
outline-offset: 1px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
|
|
@ -816,6 +674,49 @@
|
|||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Consecutive email blocks — zero gap, shared outer border */
|
||||
|
||||
/* Kill margins between adjacent email wrappers */
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Strip card border/radius from every card inside a sequence */
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card,
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper .email-block-card {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
/* First in group: restore top border + top radius */
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper:not(.email-block-wrapper + .email-block-wrapper):has(+ .email-block-wrapper) .email-block-card {
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Last in group: restore bottom border + bottom radius, remove hairline */
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:not(:has(+ .email-block-wrapper)) .email-block-card {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Middle cards: just left + right borders */
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card {
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card,
|
||||
|
|
@ -966,6 +867,16 @@
|
|||
border: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-tweet-shell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-tweet-shell .react-tweet-theme {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1418,141 +1329,209 @@
|
|||
|
||||
/* Email block – Gmail style */
|
||||
.tiptap-editor .ProseMirror .email-block-card-gmail {
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-card-gmail:hover {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
/* Email badge */
|
||||
.tiptap-editor .ProseMirror .email-block-badge {
|
||||
display: inline-flex;
|
||||
/* Gmail-style two-column row */
|
||||
.tiptap-editor .ProseMirror .email-gmail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.tiptap-editor .ProseMirror .email-block-summary {
|
||||
.tiptap-editor .ProseMirror .email-gmail-row:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-row.email-gmail-row-expanded {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Sender avatar circle */
|
||||
.tiptap-editor .ProseMirror .email-gmail-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Expand button */
|
||||
.tiptap-editor .ProseMirror .email-block-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-expand-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-expand-meta {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-toggle-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-toggle-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Email details (expanded) */
|
||||
.tiptap-editor .ProseMirror .email-block-email-details {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-message {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-message-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
/* Content column */
|
||||
.tiptap-editor .ProseMirror .email-gmail-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-row {
|
||||
.tiptap-editor .ProseMirror .email-gmail-top-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-name {
|
||||
.tiptap-editor .ProseMirror .email-gmail-sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-date {
|
||||
.tiptap-editor .ProseMirror .email-gmail-date {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-subject-line {
|
||||
font-size: 12px;
|
||||
.tiptap-editor .ProseMirror .email-gmail-bottom-row {
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-subject {
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-snippet {
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-message-body {
|
||||
/* Chevron */
|
||||
.tiptap-editor .ProseMirror .email-gmail-chevron {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-chevron.email-gmail-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Expanded area */
|
||||
.tiptap-editor .ProseMirror .email-gmail-expanded {
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-subject {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Metadata strip (avatar + from/to/date + open button) */
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-meta-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-to-date {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-fulldate {
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-open-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: none;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-open-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Email body */
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-body {
|
||||
font-size: 14px;
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.58;
|
||||
line-height: 1.6;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-context-section {
|
||||
/* Earlier conversation */
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-context-label {
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-history-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -1560,68 +1539,88 @@
|
|||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-context-summary {
|
||||
font-size: 14px;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
line-height: 1.58;
|
||||
.tiptap-editor .ProseMirror .email-gmail-exp-history-body {
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Draft section */
|
||||
.tiptap-editor .ProseMirror .email-block-draft-section {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
border-radius: 6px;
|
||||
/* Compose / draft box */
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose {
|
||||
margin-top: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-draft-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
margin-bottom: 4px;
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-draft-block-body-input {
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-addr {
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-body {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px 0;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.58;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder {
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.tiptap-editor .ProseMirror .email-block-actions {
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn {
|
||||
/* Action buttons */
|
||||
.tiptap-editor .ProseMirror .email-gmail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
|
@ -1629,24 +1628,19 @@
|
|||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary {
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary {
|
||||
color: #fff;
|
||||
background: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary:hover:not(:disabled) {
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover {
|
||||
background: #1765cc;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
|
||||
color: #fff;
|
||||
|
|
@ -1661,6 +1655,167 @@
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Reply / Forward pill buttons (in expanded view) */
|
||||
.tiptap-editor .ProseMirror .email-gmail-reply-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-reply-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, box-shadow 0.12s ease;
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-reply-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-reply-row-end {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Emails inbox block (language-emails) ---- */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-card {
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Each email row — hairline separator only, no card */
|
||||
.tiptap-editor .ProseMirror .email-inbox-row {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 4px 7px 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-row-header:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-row.email-inbox-row-expanded .email-inbox-row-header {
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.tiptap-editor .ProseMirror .email-inbox-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Content column — two-line layout */
|
||||
.tiptap-editor .ProseMirror .email-inbox-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-top-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-date {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-bottom-row {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-subject {
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-snippet {
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Expand chevron */
|
||||
.tiptap-editor .ProseMirror .email-inbox-chevron {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-chevron.email-inbox-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Expanded content padding */
|
||||
.tiptap-editor .ProseMirror .email-inbox-expanded-wrap {
|
||||
padding: 8px 0 12px 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Transcript block */
|
||||
.tiptap-editor .ProseMirror .transcript-block-toggle {
|
||||
display: flex;
|
||||
|
|
@ -1865,3 +2020,33 @@
|
|||
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Read-only renderer used by surfaces that need rich blocks without editor chrome. */
|
||||
.rich-markdown-viewer {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.rich-markdown-viewer .ProseMirror {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-viewer .ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-markdown-viewer .ProseMirror .task-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .image-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .embed-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .iframe-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .chart-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .table-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .calendar-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .email-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .email-draft-block-delete,
|
||||
.rich-markdown-viewer .ProseMirror .mermaid-block-delete {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
/* =============================================================
|
||||
Track Modal — dialog overlay for track block details / edits
|
||||
Track sidebar styles. Filename is legacy (predates the modal →
|
||||
sidebar refactor); the .track-modal-* class names are reused by
|
||||
the sidebar's detail-view layout.
|
||||
============================================================= */
|
||||
|
||||
.track-modal-content {
|
||||
|
|
@ -309,3 +311,167 @@
|
|||
.track-modal-run-btn:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 85%, black);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track sidebar — right panel that lists/edits tracks for the
|
||||
currently-open note. Reuses the .track-modal-* inner styles.
|
||||
============================================================= */
|
||||
|
||||
.track-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background, #fff);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 24px -12px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.track-sidebar-back,
|
||||
.track-sidebar-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-back:hover,
|
||||
.track-sidebar-close:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-sidebar-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track-sidebar-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.track-sidebar-empty-hint {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row {
|
||||
--track-accent: #64748b;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--track-accent);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.track-sidebar-row[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-sidebar-row[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-sidebar-row[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-sidebar-row[data-active="false"] { opacity: 0.65; }
|
||||
|
||||
.track-sidebar-row:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row-icon {
|
||||
color: var(--track-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.track-sidebar-row-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-sidebar-row-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track-sidebar-row-sub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row-instruction {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-sidebar-detail {
|
||||
--track-accent: #64748b;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track-sidebar-detail[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-sidebar-detail[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-sidebar-detail[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-sidebar-detail[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
"openid-client": "^6.8.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"posthog-node": "^4.18.0",
|
||||
"react": "^19.2.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"yaml": "^2.8.2",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
|
||||
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
|
||||
import { MessageEvent } from "@x/shared/dist/runs.js";
|
||||
import { createRun } from "../runs/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
const DEFAULT_STARTING_MESSAGE = "go";
|
||||
|
|
@ -162,8 +163,12 @@ async function runAgent(
|
|||
});
|
||||
|
||||
try {
|
||||
// Create a new run
|
||||
const run = await runsRepo.create({ agentId: agentName });
|
||||
// Create a new run via core (resolves agent + default model+provider).
|
||||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
useCase: 'copilot_chat',
|
||||
subUseCase: 'scheduled',
|
||||
});
|
||||
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
|
||||
|
||||
// Add the starting message as a user message
|
||||
|
|
|
|||
104
apps/x/packages/core/src/agents/build-trigger-block.ts
Normal file
104
apps/x/packages/core/src/agents/build-trigger-block.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { Triggers } from '@x/shared/dist/live-note.js';
|
||||
|
||||
export type TriggerType = 'manual' | 'cron' | 'window' | 'event';
|
||||
|
||||
export interface BuildTriggerBlockOptions {
|
||||
trigger: TriggerType;
|
||||
triggers?: Triggers;
|
||||
|
||||
/** For 'manual' / 'cron' / 'window' branches — extra context for THIS run. */
|
||||
context?: string;
|
||||
|
||||
/** For 'event' branch — the matched event's payload. */
|
||||
eventPayload?: string;
|
||||
|
||||
/**
|
||||
* Noun for the target entity in the event-branch wording — "flagged this
|
||||
* {targetNoun}", "Event match criteria for this {targetNoun}:". Live-note
|
||||
* passes 'note'; bg-task passes 'task'. Default 'target'.
|
||||
*/
|
||||
targetNoun?: string;
|
||||
|
||||
/**
|
||||
* Noun for the user's persistent intent — "if your {instructionsNoun}
|
||||
* specifies different behavior…" in the cron/window branches. Live-note
|
||||
* passes 'objective'; bg-task uses the default 'instructions'.
|
||||
*/
|
||||
instructionsNoun?: string;
|
||||
|
||||
/**
|
||||
* Text shown inside the manual-trigger parenthetical, after "Manual run".
|
||||
* Live-note passes:
|
||||
* "user-triggered — either the Run button in the Live Note panel or the
|
||||
* `run-live-note-agent` tool"
|
||||
* Bg-task passes:
|
||||
* "user-triggered — either the Run button in the Background Task detail
|
||||
* view or the `run-background-task-agent` tool"
|
||||
*/
|
||||
manualParen?: string;
|
||||
|
||||
/**
|
||||
* The "**Decision:** …" paragraph appended to the event branch. Live-note
|
||||
* and bg-task pass their own copies so the directive matches their
|
||||
* domain (edit the file vs. act on the event).
|
||||
*/
|
||||
eventDecisionDirective?: string;
|
||||
}
|
||||
|
||||
function describeWindow(triggers: Triggers | undefined): string {
|
||||
const ws = triggers?.windows;
|
||||
if (!ws || ws.length === 0) return 'a configured window';
|
||||
return ws.map(w => `${w.startTime}–${w.endTime}`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "**Trigger:** …" paragraph appended to a scheduled/event/manual
|
||||
* agent message. Shared between the live-note runner and the bg-task runner —
|
||||
* each passes domain-specific nouns and the event-branch decision directive.
|
||||
*/
|
||||
export function buildTriggerBlock(opts: BuildTriggerBlockOptions): string {
|
||||
const {
|
||||
trigger,
|
||||
triggers,
|
||||
context,
|
||||
eventPayload,
|
||||
targetNoun = 'target',
|
||||
instructionsNoun = 'instructions',
|
||||
manualParen = 'user-triggered',
|
||||
eventDecisionDirective,
|
||||
} = opts;
|
||||
|
||||
if (trigger === 'event') {
|
||||
const criteria = triggers?.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)';
|
||||
const decision = eventDecisionDirective ?? '';
|
||||
return `
|
||||
|
||||
**Trigger:** Event match — Pass 1 routing flagged this ${targetNoun} as potentially relevant to the event below.
|
||||
|
||||
**Event match criteria for this ${targetNoun}:**
|
||||
${criteria}
|
||||
|
||||
**Event payload:**
|
||||
${eventPayload ?? '(no payload)'}
|
||||
|
||||
${decision}`;
|
||||
}
|
||||
|
||||
if (trigger === 'cron') {
|
||||
const expr = triggers?.cronExpr ?? '(unknown)';
|
||||
return `
|
||||
|
||||
**Trigger:** Scheduled refresh — the cron expression \`${expr}\` matched. This is a baseline refresh; if your ${instructionsNoun} specifies different behavior for cron vs window vs event runs, follow the cron branch.${context ? `\n\n**Context:**\n${context}` : ''}`;
|
||||
}
|
||||
|
||||
if (trigger === 'window') {
|
||||
return `
|
||||
|
||||
**Trigger:** Scheduled refresh — fired inside the configured window (${describeWindow(triggers)}). This is a forgiving baseline refresh that runs once per day per window; reactive updates are handled by event triggers (when configured). If your ${instructionsNoun} specifies different behavior for cron vs window vs event runs, follow the window branch.${context ? `\n\n**Context:**\n${context}` : ''}`;
|
||||
}
|
||||
|
||||
// manual
|
||||
return `
|
||||
|
||||
**Trigger:** Manual run (${manualParen}).${context ? `\n\n**Context:**\n${context}` : ''}`;
|
||||
}
|
||||
|
|
@ -11,13 +11,13 @@ import { execTool } from "../application/lib/exec-tool.js";
|
|||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||
import { buildTrackRunAgent } from "../knowledge/track/run-agent.js";
|
||||
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
|
||||
import { buildBackgroundTaskAgent } from "../background-tasks/agent.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getGatewayProvider } from "../models/gateway.js";
|
||||
import { resolveProviderConfig } from "../models/defaults.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
|
@ -27,6 +27,8 @@ import { IRunsLock } from "../runs/lock.js";
|
|||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { parse } from "yaml";
|
||||
import { captureLlmUsage } from "../analytics/usage.js";
|
||||
import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js";
|
||||
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
||||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
|
|
@ -34,6 +36,19 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.
|
|||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
|
||||
|
||||
function loadUserWorkDir(): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
||||
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { path?: unknown };
|
||||
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
||||
return value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAgentNotesContext(): string | null {
|
||||
const sections: string[] = [];
|
||||
|
|
@ -162,6 +177,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
modelConfigRepo: this.modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry: this.abortRegistry,
|
||||
bus: this.bus,
|
||||
})) {
|
||||
eventCount++;
|
||||
if (event.type !== "llm-stream-event") {
|
||||
|
|
@ -194,6 +210,19 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
|
||||
await this.bus.publish(stoppedEvent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Run ${runId} failed:`, error);
|
||||
const message = error instanceof Error
|
||||
? (error.stack || error.message || error.name)
|
||||
: typeof error === "string" ? error : JSON.stringify(error);
|
||||
const errorEvent: z.infer<typeof RunEvent> = {
|
||||
runId,
|
||||
type: "error",
|
||||
error: message,
|
||||
subflow: [],
|
||||
};
|
||||
await this.runsRepo.appendEvents(runId, [errorEvent]);
|
||||
await this.bus.publish(errorEvent);
|
||||
} finally {
|
||||
this.abortRegistry.cleanup(runId);
|
||||
await this.runsLock.release(runId);
|
||||
|
|
@ -373,8 +402,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return buildCopilotAgent();
|
||||
}
|
||||
|
||||
if (id === "track-run") {
|
||||
return buildTrackRunAgent();
|
||||
if (id === "live-note-agent") {
|
||||
return buildLiveNoteAgent();
|
||||
}
|
||||
|
||||
if (id === "background-task-agent") {
|
||||
return buildBackgroundTaskAgent();
|
||||
}
|
||||
|
||||
if (id === 'note_creation') {
|
||||
|
|
@ -636,6 +669,10 @@ export class AgentState {
|
|||
runId: string | null = null;
|
||||
agent: z.infer<typeof Agent> | null = null;
|
||||
agentName: string | null = null;
|
||||
runModel: string | null = null;
|
||||
runProvider: string | null = null;
|
||||
runUseCase: UseCase | null = null;
|
||||
runSubUseCase: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
|
|
@ -749,13 +786,22 @@ export class AgentState {
|
|||
case "start":
|
||||
this.runId = event.runId;
|
||||
this.agentName = event.agentName;
|
||||
this.runModel = event.model;
|
||||
this.runProvider = event.provider;
|
||||
this.runUseCase = event.useCase ?? null;
|
||||
this.runSubUseCase = event.subUseCase ?? null;
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||
// Subflows inherit the parent run's model+provider — there's one pair per run.
|
||||
if (!this.subflowStates[event.toolCallId]) {
|
||||
this.subflowStates[event.toolCallId] = new AgentState();
|
||||
}
|
||||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||
break;
|
||||
case "message":
|
||||
this.messages.push(event.message);
|
||||
|
|
@ -828,6 +874,7 @@ export async function* streamAgent({
|
|||
modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry,
|
||||
bus,
|
||||
}: {
|
||||
state: AgentState,
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
|
|
@ -836,6 +883,7 @@ export async function* streamAgent({
|
|||
modelConfigRepo: IModelConfigRepo;
|
||||
signal: AbortSignal;
|
||||
abortRegistry: IAbortRegistry;
|
||||
bus: IBus;
|
||||
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
|
||||
|
||||
|
|
@ -844,35 +892,31 @@ export async function* streamAgent({
|
|||
yield event;
|
||||
}
|
||||
|
||||
const modelConfig = await modelConfigRepo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
|
||||
// set up agent
|
||||
const agent = await loadAgent(state.agentName!);
|
||||
|
||||
// set up tools
|
||||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(modelConfig.provider);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
|
||||
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
|
||||
const isInlineTaskAgent = state.agentName === "inline_task_agent";
|
||||
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
|
||||
const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel;
|
||||
const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel;
|
||||
const modelId = isInlineTaskAgent
|
||||
? defaultInlineTaskModel
|
||||
: (isKgAgent && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: isKgAgent ? defaultKgModel : defaultModel;
|
||||
// model+provider were resolved and frozen on the run at runs:create time.
|
||||
// Look up the named provider's current credentials from models.json and
|
||||
// instantiate the LLM client. No selection happens here.
|
||||
if (!state.runModel || !state.runProvider) {
|
||||
throw new Error(`Run ${runId} is missing model/provider on its start event`);
|
||||
}
|
||||
const modelId = state.runModel;
|
||||
const providerConfig = await resolveProviderConfig(state.runProvider);
|
||||
const provider = createProvider(providerConfig);
|
||||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId}`);
|
||||
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
|
||||
|
||||
// Install use-case context for tool-internal LLM calls (e.g. parseFile)
|
||||
// so they can tag their `llm_usage` events with the parent run's category.
|
||||
enterUseCase({
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||
...(state.agentName ? { agentName: state.agentName } : {}),
|
||||
});
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
|
|
@ -942,27 +986,47 @@ export async function* streamAgent({
|
|||
subflow: [],
|
||||
});
|
||||
let result: unknown = null;
|
||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||
const subflowState = state.subflowStates[toolCallId];
|
||||
for await (const event of streamAgent({
|
||||
state: subflowState,
|
||||
idGenerator,
|
||||
runId,
|
||||
try {
|
||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||
const subflowState = state.subflowStates[toolCallId];
|
||||
for await (const event of streamAgent({
|
||||
state: subflowState,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry,
|
||||
bus,
|
||||
})) {
|
||||
yield* processEvent({
|
||||
...event,
|
||||
subflow: [toolCallId, ...event.subflow],
|
||||
});
|
||||
}
|
||||
if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) {
|
||||
result = subflowState.finalResponse();
|
||||
}
|
||||
yield* processEvent({
|
||||
...event,
|
||||
subflow: [toolCallId, ...event.subflow],
|
||||
});
|
||||
}
|
||||
if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) {
|
||||
result = subflowState.finalResponse();
|
||||
}
|
||||
} else {
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, {
|
||||
runId,
|
||||
toolCallId,
|
||||
signal,
|
||||
abortRegistry,
|
||||
publish: (event) => bus.publish(event),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error instanceof Error && error.name === "AbortError") || signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? (error.message || error.name) : String(error);
|
||||
_logger.log('tool failed', message);
|
||||
result = {
|
||||
success: false,
|
||||
error: message,
|
||||
toolName: toolCall.toolName,
|
||||
};
|
||||
}
|
||||
const resultPayload = result === undefined ? null : result;
|
||||
const resultMsg: z.infer<typeof ToolMessage> = {
|
||||
|
|
@ -1058,6 +1122,28 @@ export async function* streamAgent({
|
|||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
const userWorkDir = loadUserWorkDir();
|
||||
if (userWorkDir) {
|
||||
loopLogger.log('injecting user work directory', userWorkDir);
|
||||
instructionsWithDateTime += `\n\n# User Work Directory
|
||||
The user has chosen the following directory as their current **work directory**:
|
||||
|
||||
\`${userWorkDir}\`
|
||||
|
||||
Treat this as the **default location** for file operations whenever the user refers to files generically:
|
||||
- "list the files", "show me what's in here", "what's the latest report" — list or look in the work directory.
|
||||
- "save this", "export it", "write that to a file" — write the output into the work directory unless the user names another location.
|
||||
- "open the file I was just working on", "the doc from earlier" — assume the work directory first.
|
||||
|
||||
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
|
||||
|
||||
**Exceptions — these ALWAYS take precedence over the work directory default:**
|
||||
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
|
||||
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
|
||||
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
|
||||
|
||||
Do not announce the work directory unless it's relevant. Just use it.`;
|
||||
}
|
||||
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
|
||||
// that supersedes any earlier middle-pane mention in the conversation history.
|
||||
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
|
||||
|
|
@ -1094,6 +1180,13 @@ export async function* streamAgent({
|
|||
instructionsWithDateTime,
|
||||
tools,
|
||||
signal,
|
||||
{
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||
agentName: state.agentName ?? undefined,
|
||||
modelId,
|
||||
providerName: state.runProvider!,
|
||||
},
|
||||
)) {
|
||||
messageBuilder.ingest(event);
|
||||
yield* processEvent({
|
||||
|
|
@ -1181,23 +1274,46 @@ export async function* streamAgent({
|
|||
}
|
||||
}
|
||||
|
||||
interface StreamLlmAnalytics {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
modelId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
async function* streamLlm(
|
||||
model: LanguageModel,
|
||||
messages: z.infer<typeof MessageList>,
|
||||
instructions: string,
|
||||
tools: ToolSet,
|
||||
signal?: AbortSignal,
|
||||
analytics?: StreamLlmAnalytics,
|
||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||
const converted = convertFromMessages(messages);
|
||||
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
||||
const { fullStream } = streamText({
|
||||
model,
|
||||
messages: converted,
|
||||
system: instructions,
|
||||
tools,
|
||||
stopWhen: stepCountIs(1),
|
||||
abortSignal: signal,
|
||||
});
|
||||
const streamResult = analytics
|
||||
? withUseCase({
|
||||
useCase: analytics.useCase,
|
||||
...(analytics.subUseCase ? { subUseCase: analytics.subUseCase } : {}),
|
||||
...(analytics.agentName ? { agentName: analytics.agentName } : {}),
|
||||
}, () => streamText({
|
||||
model,
|
||||
messages: converted,
|
||||
system: instructions,
|
||||
tools,
|
||||
stopWhen: stepCountIs(1),
|
||||
abortSignal: signal,
|
||||
}))
|
||||
: streamText({
|
||||
model,
|
||||
messages: converted,
|
||||
system: instructions,
|
||||
tools,
|
||||
stopWhen: stepCountIs(1),
|
||||
abortSignal: signal,
|
||||
});
|
||||
const { fullStream } = streamResult;
|
||||
for await (const event of fullStream) {
|
||||
// Check abort on every chunk for responsiveness
|
||||
signal?.throwIfAborted();
|
||||
|
|
@ -1257,6 +1373,16 @@ async function* streamLlm(
|
|||
};
|
||||
break;
|
||||
case "finish-step":
|
||||
if (analytics) {
|
||||
captureLlmUsage({
|
||||
useCase: analytics.useCase,
|
||||
...(analytics.subUseCase ? { subUseCase: analytics.subUseCase } : {}),
|
||||
...(analytics.agentName ? { agentName: analytics.agentName } : {}),
|
||||
model: analytics.modelId,
|
||||
provider: analytics.providerName,
|
||||
usage: event.usage,
|
||||
});
|
||||
}
|
||||
yield {
|
||||
type: "finish-step",
|
||||
usage: event.usage,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,35 @@
|
|||
import { bus } from "../runs/bus.js";
|
||||
import { fetchRun } from "../runs/runs.js";
|
||||
|
||||
type RunRecord = Awaited<ReturnType<typeof fetchRun>>;
|
||||
|
||||
function extractRunErrors(run: RunRecord): string[] {
|
||||
return run.log.flatMap((event) => event.type === "error" ? [event.error] : []);
|
||||
}
|
||||
|
||||
export class RunFailedError extends Error {
|
||||
readonly runId: string;
|
||||
readonly errors: string[];
|
||||
|
||||
constructor(runId: string, errors: string[]) {
|
||||
const firstError = errors.find(Boolean) ?? null;
|
||||
super(firstError ? `Run ${runId} failed: ${firstError}` : `Run ${runId} failed`);
|
||||
this.name = "RunFailedError";
|
||||
this.runId = runId;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorDetails(error: unknown): string {
|
||||
if (error instanceof RunFailedError) {
|
||||
return error.errors.join("\n\n");
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assistant's final text response from a run's log.
|
||||
* @param runId
|
||||
|
|
@ -28,13 +57,28 @@ export async function extractAgentResponse(runId: string): Promise<string | null
|
|||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
export async function waitForRunCompletion(runId: string): Promise<void> {
|
||||
return new Promise(async (resolve) => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
export async function waitForRunCompletion(
|
||||
runId: string,
|
||||
opts: { throwOnError?: boolean } = {},
|
||||
): Promise<RunRecord> {
|
||||
return new Promise((resolve, reject) => {
|
||||
void (async () => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
try {
|
||||
const run = await fetchRun(runId);
|
||||
const errors = extractRunErrors(run);
|
||||
if (opts.throwOnError && errors.length > 0) {
|
||||
reject(new RunFailedError(runId, errors));
|
||||
return;
|
||||
}
|
||||
resolve(run);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
})().catch(reject);
|
||||
});
|
||||
}
|
||||
23
apps/x/packages/core/src/analytics/identify.ts
Normal file
23
apps/x/packages/core/src/analytics/identify.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { isSignedIn } from '../account/account.js';
|
||||
import { getBillingInfo } from '../billing/billing.js';
|
||||
import { identify } from './posthog.js';
|
||||
|
||||
/**
|
||||
* If the user has rowboat OAuth tokens, fetch their billing info and
|
||||
* call posthog.identify(). Idempotent — safe to call on every app start.
|
||||
* Catches all errors so analytics never blocks app launch.
|
||||
*/
|
||||
export async function identifyIfSignedIn(): Promise<void> {
|
||||
try {
|
||||
if (!(await isSignedIn())) return;
|
||||
const billing = await getBillingInfo();
|
||||
if (!billing.userId) return;
|
||||
identify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] startup identify failed:', err);
|
||||
}
|
||||
}
|
||||
37
apps/x/packages/core/src/analytics/installation.ts
Normal file
37
apps/x/packages/core/src/analytics/installation.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const INSTALLATION_PATH = path.join(WorkDir, 'config', 'installation.json');
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
export function getInstallationId(): string {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
if (fs.existsSync(INSTALLATION_PATH)) {
|
||||
const raw = fs.readFileSync(INSTALLATION_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { installationId?: string };
|
||||
if (parsed.installationId && typeof parsed.installationId === 'string') {
|
||||
cached = parsed.installationId;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to read installation.json:', err);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
const dir = path.dirname(INSTALLATION_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(INSTALLATION_PATH, JSON.stringify({ installationId: id }, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to write installation.json:', err);
|
||||
}
|
||||
cached = id;
|
||||
return id;
|
||||
}
|
||||
90
apps/x/packages/core/src/analytics/posthog.ts
Normal file
90
apps/x/packages/core/src/analytics/posthog.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { PostHog } from 'posthog-node';
|
||||
import { getInstallationId } from './installation.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
// Build-time injected via esbuild `define` (apps/main/bundle.mjs).
|
||||
// In dev/tsc, fall back to process.env so local runs work too.
|
||||
const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? '';
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com';
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let initAttempted = false;
|
||||
let identifiedUserId: string | null = null;
|
||||
|
||||
function getClient(): PostHog | null {
|
||||
if (initAttempted) return client;
|
||||
initAttempted = true;
|
||||
if (!POSTHOG_KEY) {
|
||||
console.log('[Analytics] POSTHOG_KEY not set; analytics disabled');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
client = new PostHog(POSTHOG_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 20,
|
||||
flushInterval: 10_000,
|
||||
});
|
||||
// Tag the install with api_url as a person property up-front,
|
||||
// so anonymous users are also segmentable by environment (api_url
|
||||
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
||||
client.identify({
|
||||
distinctId: getInstallationId(),
|
||||
properties: { api_url: API_URL },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to init PostHog:', err);
|
||||
client = null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function activeDistinctId(): string {
|
||||
return identifiedUserId ?? getInstallationId();
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
ph.capture({
|
||||
distinctId: activeDistinctId(),
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] capture failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function identify(userId: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
// Alias the anonymous installation ID to the rowboat user ID so historical
|
||||
// anonymous events are linked to the identified user.
|
||||
ph.alias({ distinctId: userId, alias: getInstallationId() });
|
||||
ph.identify({
|
||||
distinctId: userId,
|
||||
properties: {
|
||||
...properties,
|
||||
api_url: API_URL,
|
||||
},
|
||||
});
|
||||
identifiedUserId = userId;
|
||||
} catch (err) {
|
||||
console.error('[Analytics] identify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function reset(): void {
|
||||
identifiedUserId = null;
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.shutdown();
|
||||
} catch (err) {
|
||||
console.error('[Analytics] shutdown failed:', err);
|
||||
}
|
||||
}
|
||||
38
apps/x/packages/core/src/analytics/usage.ts
Normal file
38
apps/x/packages/core/src/analytics/usage.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { capture } from './posthog.js';
|
||||
import type { UseCase } from './use_case.js';
|
||||
|
||||
// Shape compatible with ai-sdk v5 `LanguageModelUsage`.
|
||||
// All fields are optional because providers report subsets.
|
||||
export interface LlmUsageInput {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
}
|
||||
|
||||
export interface CaptureLlmUsageArgs {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
usage: LlmUsageInput | undefined;
|
||||
}
|
||||
|
||||
export function captureLlmUsage(args: CaptureLlmUsageArgs): void {
|
||||
const usage = args.usage ?? {};
|
||||
const properties: Record<string, unknown> = {
|
||||
use_case: args.useCase,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
input_tokens: usage.inputTokens ?? 0,
|
||||
output_tokens: usage.outputTokens ?? 0,
|
||||
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
};
|
||||
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
|
||||
if (args.agentName) properties.agent_name = args.agentName;
|
||||
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
|
||||
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
|
||||
capture('llm_usage', properties);
|
||||
}
|
||||
28
apps/x/packages/core/src/analytics/use_case.ts
Normal file
28
apps/x/packages/core/src/analytics/use_case.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<UseCaseContext>();
|
||||
|
||||
export function withUseCase<T>(ctx: UseCaseContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently install a use-case context for the current async chain.
|
||||
* Use inside generator functions where wrapping with `withUseCase()` doesn't
|
||||
* compose. Child async work (e.g. tool execution) will inherit it.
|
||||
*/
|
||||
export function enterUseCase(ctx: UseCaseContext): void {
|
||||
storage.enterWith(ctx);
|
||||
}
|
||||
|
||||
export function getCurrentUseCase(): UseCaseContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
|
@ -78,13 +78,23 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
|
||||
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
|
||||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
|
||||
|
||||
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
|
||||
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
|
||||
**Background Tasks (Self-Running Work):** Rowboat can run *background tasks* — persistent instructions the agent fires on a schedule and/or in response to incoming emails / calendar events. A bg-task either maintains a snapshot in its \`index.md\` (digest, dashboard, rolling summary) or performs a recurring side-effect (send a Slack message, draft an email, post to a webhook, call an API). This is the flagship surface for *anything recurring*.
|
||||
|
||||
*Strong signals (load the \`background-task\` skill, act without asking):* cadence words ("every morning / daily / hourly / each Monday…"), "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "send me X each morning…", "whenever a relevant email comes in, X…", action verbs ("draft / reply / call / post / notify / file / brief me on…"), "track / follow X".
|
||||
|
||||
*Medium signals (load the skill, answer the one-off, then offer):* one-off questions about decaying info ("what's the weather?", "top HN stories?"), "what's the latest on X / catch me up on X / any updates on X" about a person, company, project, or topic, recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic:** if you reach for \`web-search\` or a news tool to answer a recurring question, the answer is the kind of thing a bg-task would refresh on a schedule.
|
||||
|
||||
**Live Notes:** If the user explicitly says "live note" or "live-note", load the \`live-note\` skill. Otherwise, do not propose live notes — prefer the \`background-task\` skill for anything recurring.
|
||||
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
|
||||
|
||||
**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
|
||||
|
||||
|
||||
## Learning About the User (save-to-memory)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,15 @@ export interface RuntimeContext {
|
|||
}
|
||||
|
||||
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||
if (platform === 'win32') {
|
||||
return process.env.ComSpec || 'cmd.exe';
|
||||
}
|
||||
|
||||
if (process.env.SHELL) {
|
||||
return process.env.SHELL;
|
||||
}
|
||||
|
||||
return platform === 'darwin' ? '/bin/zsh' : '/bin/sh';
|
||||
}
|
||||
|
||||
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||
|
|
|
|||
|
|
@ -1,555 +0,0 @@
|
|||
export const skill = String.raw`
|
||||
# Background Agents
|
||||
|
||||
Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent.
|
||||
|
||||
- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
|
||||
- Agents configure a model, tools (in frontmatter), and instructions (in the body)
|
||||
- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents**
|
||||
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
|
||||
- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root
|
||||
|
||||
## How multi-agent workflows work
|
||||
|
||||
1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + `
|
||||
2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below)
|
||||
3. The orchestrator calls other agents as tools when needed
|
||||
4. Data flows through tool call parameters and responses
|
||||
|
||||
## Scheduling Background Agents
|
||||
|
||||
Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root.
|
||||
|
||||
### Schedule Configuration File
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
"agent_name": {
|
||||
"schedule": { ... },
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Schedule Types
|
||||
|
||||
**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat).
|
||||
|
||||
**1. Cron Schedule** - Runs at exact times defined by cron expression
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 8 * * *"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
Common cron expressions:
|
||||
- ` + "`*/5 * * * *`" + ` - Every 5 minutes
|
||||
- ` + "`0 8 * * *`" + ` - Every day at 8am
|
||||
- ` + "`0 9 * * 1`" + ` - Every Monday at 9am
|
||||
- ` + "`0 0 1 * *`" + ` - First day of every month at midnight
|
||||
|
||||
**2. Window Schedule** - Runs once during a time window
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": {
|
||||
"type": "window",
|
||||
"cron": "0 0 * * *",
|
||||
"startTime": "08:00",
|
||||
"endTime": "10:00"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am").
|
||||
|
||||
**3. Once Schedule** - Runs exactly once at a specific time
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": {
|
||||
"type": "once",
|
||||
"runAt": "2024-02-05T10:30:00"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix).
|
||||
|
||||
### Starting Message
|
||||
|
||||
You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `.
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": { "type": "cron", "expression": "0 8 * * *" },
|
||||
"enabled": true,
|
||||
"startingMessage": "Please summarize my emails from the last 24 hours"
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Description
|
||||
|
||||
You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI.
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": { "type": "cron", "expression": "0 8 * * *" },
|
||||
"enabled": true,
|
||||
"description": "Summarizes emails and calendar events every morning"
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Complete Schedule Example
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
"daily_digest": {
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 8 * * *"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "Daily email and calendar summary",
|
||||
"startingMessage": "Summarize my emails and calendar for today"
|
||||
},
|
||||
"morning_briefing": {
|
||||
"schedule": {
|
||||
"type": "window",
|
||||
"cron": "0 0 * * *",
|
||||
"startTime": "07:00",
|
||||
"endTime": "09:00"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "Morning news and updates briefing"
|
||||
},
|
||||
"one_time_setup": {
|
||||
"schedule": {
|
||||
"type": "once",
|
||||
"runAt": "2024-12-01T12:00:00"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "One-time data migration task"
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Schedule State (Read-Only)
|
||||
|
||||
**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
|
||||
|
||||
The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root:
|
||||
- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules)
|
||||
- ` + "`lastRunAt`" + `: When the agent last ran
|
||||
- ` + "`nextRunAt`" + `: When the agent will run next
|
||||
- ` + "`lastError`" + `: Error message if the last run failed
|
||||
- ` + "`runCount`" + `: Total number of runs
|
||||
|
||||
When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `.
|
||||
|
||||
## Agent File Format
|
||||
|
||||
Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions.
|
||||
|
||||
### Basic Structure
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
tool_key:
|
||||
type: builtin
|
||||
name: tool_name
|
||||
---
|
||||
# Instructions
|
||||
|
||||
Your detailed instructions go here in Markdown format.
|
||||
` + "```" + `
|
||||
|
||||
### Frontmatter Fields
|
||||
- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5')
|
||||
- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json
|
||||
- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions
|
||||
|
||||
### Instructions (Body)
|
||||
The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting.
|
||||
|
||||
### Naming Rules
|
||||
- Agent filename determines the agent name (without .md extension)
|
||||
- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent"
|
||||
- Use lowercase with underscores for multi-word names
|
||||
- No spaces or special characters in names
|
||||
- **The agent name in agent-schedule.json must match the filename** (without .md)
|
||||
|
||||
### Agent Format Example
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
description: Search the web
|
||||
mcpServerName: firecrawl
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Search query
|
||||
required:
|
||||
- query
|
||||
---
|
||||
# Web Search Agent
|
||||
|
||||
You are a web search agent. When asked a question:
|
||||
|
||||
1. Use the search tool to find relevant information
|
||||
2. Summarize the results clearly
|
||||
3. Cite your sources
|
||||
|
||||
Be concise and accurate.
|
||||
` + "```" + `
|
||||
|
||||
## Tool Types & Schemas
|
||||
|
||||
Tools in agents must follow one of three types. Each has specific required fields.
|
||||
|
||||
### 1. Builtin Tools
|
||||
Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.)
|
||||
|
||||
**YAML Schema:**
|
||||
` + "```yaml" + `
|
||||
tool_key:
|
||||
type: builtin
|
||||
name: tool_name
|
||||
` + "```" + `
|
||||
|
||||
**Required fields:**
|
||||
- ` + "`type`" + `: Must be "builtin"
|
||||
- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile")
|
||||
|
||||
**Example:**
|
||||
` + "```yaml" + `
|
||||
bash:
|
||||
type: builtin
|
||||
name: executeCommand
|
||||
` + "```" + `
|
||||
|
||||
**Available builtin tools:**
|
||||
- ` + "`executeCommand`" + ` - Execute shell commands
|
||||
- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations
|
||||
- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations
|
||||
- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management
|
||||
- ` + "`analyzeAgent`" + ` - Analyze agent structure
|
||||
- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management
|
||||
- ` + "`loadSkill`" + ` - Load skill guidance
|
||||
|
||||
### 2. MCP Tools
|
||||
Tools from external MCP servers (APIs, databases, web scraping, etc.)
|
||||
|
||||
**YAML Schema:**
|
||||
` + "```yaml" + `
|
||||
tool_key:
|
||||
type: mcp
|
||||
name: tool_name_from_server
|
||||
description: What the tool does
|
||||
mcpServerName: server_name_from_config
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
param:
|
||||
type: string
|
||||
description: Parameter description
|
||||
required:
|
||||
- param
|
||||
` + "```" + `
|
||||
|
||||
**Required fields:**
|
||||
- ` + "`type`" + `: Must be "mcp"
|
||||
- ` + "`name`" + `: Exact tool name from MCP server
|
||||
- ` + "`description`" + `: What the tool does (helps agent understand when to use it)
|
||||
- ` + "`mcpServerName`" + `: Server name from config/mcp.json
|
||||
- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters
|
||||
|
||||
**Example:**
|
||||
` + "```yaml" + `
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
description: Search the web
|
||||
mcpServerName: firecrawl
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Search query
|
||||
required:
|
||||
- query
|
||||
` + "```" + `
|
||||
|
||||
**Important:**
|
||||
- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server
|
||||
- Copy the schema exactly—don't modify property types or structure
|
||||
- Only include ` + "`required`" + ` array if parameters are mandatory
|
||||
|
||||
### 3. Agent Tools (for chaining agents)
|
||||
Reference other agents as tools to build multi-agent workflows
|
||||
|
||||
**YAML Schema:**
|
||||
` + "```yaml" + `
|
||||
tool_key:
|
||||
type: agent
|
||||
name: target_agent_name
|
||||
` + "```" + `
|
||||
|
||||
**Required fields:**
|
||||
- ` + "`type`" + `: Must be "agent"
|
||||
- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory)
|
||||
|
||||
**Example:**
|
||||
` + "```yaml" + `
|
||||
summariser:
|
||||
type: agent
|
||||
name: summariser_agent
|
||||
` + "```" + `
|
||||
|
||||
**How it works:**
|
||||
- Use ` + "`type: agent`" + ` to call other agents as tools
|
||||
- The target agent will be invoked with the parameters you pass
|
||||
- Results are returned as tool output
|
||||
- This is how you build multi-agent workflows
|
||||
- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `)
|
||||
|
||||
## Complete Multi-Agent Workflow Example
|
||||
|
||||
**Email digest workflow** - This is all done through agents calling other agents:
|
||||
|
||||
**1. Task-specific agent** (` + "`agents/email_reader.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
read_file:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
list_dir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
---
|
||||
# Email Reader Agent
|
||||
|
||||
Read emails from the gmail_sync folder and extract key information.
|
||||
Look for unread or recent emails and summarize the sender, subject, and key points.
|
||||
Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
email_reader:
|
||||
type: agent
|
||||
name: email_reader
|
||||
write_file:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
---
|
||||
# Daily Summary Agent
|
||||
|
||||
1. Use the email_reader tool to get email summaries
|
||||
2. Create a consolidated daily digest
|
||||
3. Save the digest to ~/Desktop/daily_digest.md
|
||||
|
||||
Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions.
|
||||
|
||||
**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
daily_summary:
|
||||
type: agent
|
||||
name: daily_summary
|
||||
search:
|
||||
type: mcp
|
||||
name: search
|
||||
mcpServerName: exa
|
||||
description: Search the web for news
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Search query
|
||||
---
|
||||
# Morning Briefing Workflow
|
||||
|
||||
Create a morning briefing:
|
||||
|
||||
1. Get email digest using daily_summary
|
||||
2. Search for relevant news using the search tool
|
||||
3. Compile a comprehensive morning briefing
|
||||
|
||||
Execute these steps in sequence. Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `:
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
"morning_briefing": {
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 7 * * *"
|
||||
},
|
||||
"enabled": true,
|
||||
"startingMessage": "Create my morning briefing for today"
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
This schedules the morning briefing workflow to run every day at 7am local time.
|
||||
|
||||
## Naming and organization rules
|
||||
- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
|
||||
- Agent filename (without .md) becomes the agent name
|
||||
- When referencing an agent as a tool, use its filename without extension
|
||||
- When scheduling an agent, use its filename without extension in agent-schedule.json
|
||||
- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users
|
||||
|
||||
## Best practices for background agents
|
||||
1. **Single responsibility**: Each agent should do one specific thing well
|
||||
2. **Clear delegation**: Agent instructions should explicitly say when to call other agents
|
||||
3. **Autonomous operation**: Add "Don't ask for human input" for background agents
|
||||
4. **Data passing**: Make it clear what data to extract and pass between agents
|
||||
5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze")
|
||||
6. **Orchestration**: Create a top-level agent that coordinates the workflow
|
||||
7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks
|
||||
8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene
|
||||
9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations
|
||||
10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md"
|
||||
|
||||
## Validation & Best Practices
|
||||
|
||||
### CRITICAL: Schema Compliance
|
||||
- Agent files MUST be valid Markdown with YAML frontmatter
|
||||
- Agent filename (without .md) becomes the agent name
|
||||
- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent")
|
||||
- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema
|
||||
- Agent tools MUST reference existing agent files
|
||||
- Invalid agents will fail to load and prevent workflow execution
|
||||
|
||||
### File Creation/Update Process
|
||||
1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter
|
||||
2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + `
|
||||
3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent
|
||||
4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `)
|
||||
5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + `
|
||||
|
||||
### Common Validation Errors to Avoid
|
||||
|
||||
❌ **WRONG - Missing frontmatter delimiters:**
|
||||
` + "```markdown" + `
|
||||
model: gpt-5.1
|
||||
# My Agent
|
||||
Instructions here
|
||||
` + "```" + `
|
||||
|
||||
❌ **WRONG - Invalid YAML indentation:**
|
||||
` + "```markdown" + `
|
||||
---
|
||||
tools:
|
||||
bash:
|
||||
type: builtin
|
||||
---
|
||||
` + "```" + `
|
||||
(bash should be indented under tools)
|
||||
|
||||
❌ **WRONG - Invalid tool type:**
|
||||
` + "```yaml" + `
|
||||
tools:
|
||||
tool1:
|
||||
type: custom
|
||||
name: something
|
||||
` + "```" + `
|
||||
(type must be builtin, mcp, or agent)
|
||||
|
||||
❌ **WRONG - Unquoted strings containing colons:**
|
||||
` + "```yaml" + `
|
||||
tools:
|
||||
search:
|
||||
description: Number of results (default: 8)
|
||||
` + "```" + `
|
||||
(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `)
|
||||
|
||||
❌ **WRONG - MCP tool missing required fields:**
|
||||
` + "```yaml" + `
|
||||
tools:
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
` + "```" + `
|
||||
(Missing: description, mcpServerName, inputSchema)
|
||||
|
||||
✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
---
|
||||
# Simple Agent
|
||||
|
||||
Do simple tasks as instructed.
|
||||
` + "```" + `
|
||||
|
||||
✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
description: Search the web
|
||||
mcpServerName: firecrawl
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
---
|
||||
# Search Agent
|
||||
|
||||
Use the search tool to find information on the web.
|
||||
` + "```" + `
|
||||
|
||||
## Capabilities checklist
|
||||
1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing
|
||||
2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes
|
||||
3. Validate YAML frontmatter syntax before creating/updating agents
|
||||
4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update
|
||||
5. When creating multi-agent workflows, create an orchestrator agent
|
||||
6. Add other agents as tools with ` + "`type: agent`" + ` for chaining
|
||||
7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations
|
||||
8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
|
||||
9. Confirm work done and outline next steps once changes are complete
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { BackgroundTaskSchema } from '@x/shared/dist/background-task.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(BackgroundTaskSchema)).trimEnd();
|
||||
|
||||
export const skill = String.raw`
|
||||
# Background Tasks Skill
|
||||
|
||||
A *background task* is a persistent agent the user configures once and the framework keeps firing — on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at \`bg-tasks/<slug>/\` and owns two artifacts:
|
||||
|
||||
- \`task.yaml\` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth.
|
||||
- \`index.md\` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run.
|
||||
|
||||
A task is one of two shapes — the agent decides per run from the verbs in \`instructions\`:
|
||||
|
||||
| Mode | Trigger verbs | Behavior |
|
||||
|---|---|---|
|
||||
| **OUTPUT** | "maintain / show / summarize / track / digest" | Rewrite \`index.md\` to reflect the current state. |
|
||||
| **ACTION** | "send / draft / post / notify / file / reply / call" | Perform the action, then append a one-line journal entry under \`## Journal\` in \`index.md\`. |
|
||||
|
||||
Mixed instructions ("summarize and email it") trigger both.
|
||||
|
||||
## Tools you'll use (and ones you WON'T)
|
||||
|
||||
You have three dedicated builtin tools for this skill:
|
||||
|
||||
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`workspace-edit\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
|
||||
- \`patch-background-task\` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case.
|
||||
- \`run-background-task-agent\` — manually fires a task to run now. Always call this immediately after \`create-background-task\` so the user sees content.
|
||||
|
||||
To inspect what tasks already exist, use \`workspace-glob\` on \`bg-tasks/*/task.yaml\` and \`workspace-readFile\` on candidates. The user's bg-tasks folder is workspace-relative.
|
||||
|
||||
## Mode: act-first
|
||||
|
||||
Bg-task creation is **action-first**. Don't ask "should I?" — read the request, pick a name, call \`create-background-task\`, then call \`run-background-task-agent\` with the returned slug. Confirm in one line past-tense at the end. Tell the user the surface name: "Manage it from Background tasks in the sidebar."
|
||||
|
||||
The only exception: if a related bg-task already exists, **extend its instructions** via \`patch-background-task\` rather than creating a duplicate (see "Extend, don't fork").
|
||||
|
||||
## When you're loaded
|
||||
|
||||
The host's trigger paragraph loads this skill on:
|
||||
|
||||
- **Cadence**: "every morning", "daily", "hourly", "each Monday"
|
||||
- **Watch/monitor**: "watch / monitor / keep an eye on / track / follow X"
|
||||
- **Recurring artifact**: "morning briefing", "weekly review", "Acme deal dashboard"
|
||||
- **Event-conditional**: "whenever a relevant email comes in, …"
|
||||
- **Action verbs**: "draft / reply / call / post / notify / file / brief me on"
|
||||
- **Decay questions**: "what's the weather", "top HN stories", "latest on X" — answer the one-off, then offer
|
||||
|
||||
If the user explicitly says "live note" / "live-note", the host loads the \`live-note\` skill instead — don't try to handle that case here.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Check for existing tasks.** Before creating, glob \`bg-tasks/*/task.yaml\` and read any candidates whose intent might overlap with the user's ask. If a related task exists, jump to "Extend, don't fork" below.
|
||||
|
||||
2. **Pick a name.** Use a short, friendly title in title-case: "Morning weather", "Q3 deal digest", "HN top stories". The framework slugifies it (lowercase, dashes) for the folder — you don't manage the slug.
|
||||
|
||||
3. **Write the instructions.** Capture the user's intent in their own words, with concrete verbs. Bake any specifics (which source, which audience, output shape) into the instructions — the agent re-reads them on every run.
|
||||
|
||||
- Good: *"Summarize my unread emails since yesterday 6pm into a one-paragraph digest plus a bulleted list of action items. Skip newsletters and automated notifications."*
|
||||
- Bad: *"Daily email summary."* (vague — agent will improvise unhelpfully)
|
||||
|
||||
4. **Pick triggers.** All three are independently optional; mix freely.
|
||||
|
||||
- \`cronExpr\` — exact times. \`"0 7 * * *"\` = 7am daily.
|
||||
- \`windows\` — time-of-day bands. Each fires once per day inside the band, anywhere — forgiving when the app was offline.
|
||||
- \`eventMatchCriteria\` — a natural-language description of which incoming events should wake the task (e.g. "Emails about Q3 OKRs from the leadership team"). Pass-1 routing matches; the agent does Pass-2 before acting.
|
||||
|
||||
No triggers at all = manual-only. The user clicks Run.
|
||||
|
||||
5. **Call \`create-background-task\`.** Required: \`name\`, \`instructions\`. Optional: \`triggers\`, \`model\`, \`provider\` (leave model/provider unset unless the user explicitly asked). The tool returns a slug.
|
||||
|
||||
6. **Call \`run-background-task-agent\`** with the slug. The agent runs once and populates \`index.md\`.
|
||||
|
||||
7. **Confirm.** One line. Name the task. Point at the sidebar. Done.
|
||||
|
||||
## Extend, don't fork
|
||||
|
||||
When the user's new ask overlaps with an existing task — e.g. they say "also include X" or the ask is a refinement of an existing task's intent — call \`patch-background-task\` instead of creating a duplicate.
|
||||
|
||||
Signals that you should extend:
|
||||
- The user says "also …" / "and on top of that …" / "while you're at it …"
|
||||
- The new ask is a refinement of an existing task's intent (different threshold, additional source, slightly different output)
|
||||
|
||||
When extending, pass the full rewritten \`instructions\` — don't try to surgical-edit a single sentence. The agent rereads instructions every run, so a clean rewrite is fine. After \`patch-background-task\` returns, call \`run-background-task-agent\` on the same slug so the user sees the updated output.
|
||||
|
||||
## Worked examples
|
||||
|
||||
### OUTPUT — morning briefing
|
||||
|
||||
User: *"Every morning at 7, give me a one-paragraph summary of overnight news in AI agents."*
|
||||
|
||||
1. \`create-background-task\` with:
|
||||
- \`name\`: "AI agent overnight news"
|
||||
- \`instructions\`: "Search the web and Hacker News for news about AI agents (autonomous LLM agents, agentic frameworks, agent benchmarks) published in the last 24 hours. Summarize the top developments in one paragraph (3-5 sentences) followed by a 3-5 item bulleted list of the most significant items with a single-sentence note each. Replace the body of index.md."
|
||||
- \`triggers\`: { \`cronExpr\`: "0 7 * * *" }
|
||||
2. \`run-background-task-agent\` slug=ai-agent-overnight-news.
|
||||
3. "Done — created the **AI agent overnight news** task. It'll run every morning at 7 and you can find it in Background tasks in the sidebar."
|
||||
|
||||
### ACTION — email auto-reply
|
||||
|
||||
User: *"Whenever I get an email about Q3 planning, draft a reply asking when they're free this week."*
|
||||
|
||||
1. \`create-background-task\` with:
|
||||
- \`name\`: "Q3 email auto-reply drafts"
|
||||
- \`instructions\`: "When an event arrives describing an email thread about Q3 planning, use the Gmail draft-create tool to draft a reply to the latest message asking the sender when they're free for a 30-minute call this week. Do not send the draft — leave it in Drafts for me to review. After drafting, append a journal entry to index.md noting the thread subject and the draft id."
|
||||
- \`triggers\`: { \`eventMatchCriteria\`: "Emails about Q3 planning (roadmap, OKRs, headcount, exec priorities)" }
|
||||
2. \`run-background-task-agent\` slug=q3-email-auto-reply-drafts.
|
||||
3. "Done — created the **Q3 email auto-reply drafts** task. It'll fire on relevant Gmail threads. Manage it from Background tasks in the sidebar."
|
||||
|
||||
### ACTION + journal — Slack watcher
|
||||
|
||||
User: *"Every weekday morning at 9, post a summary of unresolved high-priority issues to #engineering on Slack."*
|
||||
|
||||
1. \`create-background-task\` with:
|
||||
- \`name\`: "Daily eng triage"
|
||||
- \`instructions\`: "Each run, query <issue tracker> for unresolved issues labeled priority:high or above. Summarize counts by owner and the three oldest items. Send the summary to #engineering via the Slack tool. After sending, append a journal entry to index.md with the timestamp and the message id."
|
||||
- \`triggers\`: { \`cronExpr\`: "0 9 * * 1-5" }
|
||||
2. \`run-background-task-agent\` slug=daily-eng-triage.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
\`\`\`yaml
|
||||
${schemaYaml}
|
||||
\`\`\`
|
||||
|
||||
Notes:
|
||||
- \`active\` defaults to true. Patch \`{ active: false }\` to pause without deleting.
|
||||
- \`createdAt\` and \`lastRun\` are runtime-managed — never write them yourself.
|
||||
- The \`triggers\` block reuses Live Notes' \`Triggers\` schema verbatim. Cron grace and 5-minute backoff semantics are identical.
|
||||
|
||||
## Exceptions
|
||||
|
||||
The \`Background tasks\` sidebar view has a "New task" button that opens a form-driven flow. If the user is editing fields there or asking about a specific task from that view, *you* are not the right surface — the form is. Point at it ("You can also do this from the New task button in the Background tasks view") and step aside.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -14,8 +14,10 @@ Use this skill when the user asks you to open a website, browse in-app, search t
|
|||
- page ` + "`url`" + ` and ` + "`title`" + `
|
||||
- visible page text
|
||||
- interactable elements with numbered ` + "`index`" + ` values
|
||||
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
||||
5. After each action, read the returned page snapshot before deciding the next step.
|
||||
- ` + "`suggestedSkills`" + ` — site-specific and interaction-specific skill hints for the current page
|
||||
4. **Always inspect ` + "`suggestedSkills`" + ` before acting.** If any skill in the list matches what the user asked for (site or task), call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` *first*, read it in full, then plan your actions. These skills encode selectors, timing, and gotchas that would otherwise cost you several failed attempts to rediscover. If no skill matches, proceed — but do not skip this check.
|
||||
5. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
||||
6. After each action, read the returned page snapshot before deciding the next step — including re-checking ` + "`suggestedSkills`" + ` if the navigation landed you on a new domain.
|
||||
|
||||
## Actions
|
||||
|
||||
|
|
@ -92,12 +94,23 @@ Wait for the page to settle, useful after async UI changes.
|
|||
Parameters:
|
||||
- ` + "`ms`" + `: milliseconds to wait (optional)
|
||||
|
||||
## Companion Tools
|
||||
|
||||
### load-browser-skill
|
||||
Rowboat caches a library of browser skills (from ` + "`browser-use/browser-harness`" + `) indexed by both **domain** (github, linkedin, amazon, booking, …) and **interaction type** within a domain (e.g. ` + "`github/repo-actions`" + `, ` + "`github/scraping`" + `, ` + "`arxiv-bulk/*`" + `). Whenever ` + "`browser-control`" + ` returns a ` + "`suggestedSkills`" + ` array — which it does on ` + "`navigate`" + `, ` + "`new-tab`" + `, and ` + "`read-page`" + ` — treat it as a required reading step, not optional. Pick the entry that matches the current task (domain match first, then the interaction-specific variant if one exists) and call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` before attempting the action.
|
||||
|
||||
You can also proactively call ` + "`load-browser-skill({ action: \"list\", site: \"<site>\" })`" + ` when you know you're about to work on a site, to see what skills exist even if ` + "`suggestedSkills`" + ` is empty (e.g. before navigating).
|
||||
|
||||
These skills are written against a Python harness, so treat them as **reference knowledge**. Reuse the selectors, timing, and sequencing, but adapt them to Rowboat's structured browser actions. **Do not look for or call ` + "`http-fetch`" + `.** If a browser-harness recipe suggests ` + "`js(...)`" + ` or ` + "`http_get(...)`" + ` style shortcuts, treat those as non-portable and fall back to reading and interacting with the page itself.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Prefer ` + "`read-page`" + ` before interacting.
|
||||
- Prefer element ` + "`index`" + ` over CSS selectors.
|
||||
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
|
||||
- After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state.
|
||||
- **Always check ` + "`suggestedSkills`" + ` after ` + "`navigate`" + `, ` + "`new-tab`" + `, or ` + "`read-page`" + `, and load the matching domain or interaction skill before acting.** Skipping this step is the single most common way to waste a dozen failed clicks on a site whose quirks are already documented. If the array is empty, proceed normally — but don't skip the check.
|
||||
- Do not try to use ` + "`http-fetch`" + `. If a browser-harness recipe mentions ` + "`http_get(...)`" + ` or a public API shortcut, adapt it to DOM-based browsing instead.
|
||||
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
|
||||
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
|
||||
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
export const skill = String.raw`
|
||||
# Code with Agents Skill
|
||||
|
||||
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
|
||||
|
||||
## Important: delegate ALL coding work
|
||||
|
||||
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
|
||||
- Writing, editing, or refactoring code
|
||||
- Reading, summarizing, or explaining code
|
||||
- Debugging and fixing bugs
|
||||
- Running tests or build commands
|
||||
- Exploring project structure
|
||||
- Any other task that involves interacting with a codebase
|
||||
|
||||
Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The user must have one of the following installed on their machine:
|
||||
- **Claude Code** — https://claude.ai/code
|
||||
- **Codex** — https://codex.openai.com
|
||||
|
||||
These are external tools that you cannot install for the user.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather requirements
|
||||
|
||||
Before running anything, confirm the following with the user:
|
||||
|
||||
1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
|
||||
2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
|
||||
|
||||
### Step 2: Confirm execution plan
|
||||
|
||||
Once you know the folder and agent, tell the user:
|
||||
|
||||
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
|
||||
|
||||
### Step 3: Execute with acpx
|
||||
|
||||
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
|
||||
|
||||
**For Claude Code:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
|
||||
` + "`" + `
|
||||
|
||||
**For Codex:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
|
||||
` + "`" + `
|
||||
|
||||
### Critical: flag order
|
||||
|
||||
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
|
||||
|
||||
` + "`" + `
|
||||
npx acpx@latest [global flags] <agent> exec "<prompt>"
|
||||
` + "`" + `
|
||||
|
||||
**Correct:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
|
||||
` + "`" + `
|
||||
|
||||
**Wrong (will fail):**
|
||||
` + "`" + `
|
||||
npx acpx@latest claude --approve-all exec "fix the bug"
|
||||
` + "`" + `
|
||||
|
||||
### Writing good prompts
|
||||
|
||||
When constructing the prompt for the coding agent:
|
||||
- Be specific and detailed about what to build or fix
|
||||
- Include file names, function signatures, and expected behavior
|
||||
- Mention any constraints (language, framework, style)
|
||||
- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
|
||||
|
||||
### Step 4: Report results
|
||||
|
||||
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
|
||||
|
||||
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
|
||||
|
||||
- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../../lib/knowledge-note-style.js';
|
||||
|
||||
export const skill = String.raw`
|
||||
# Document Collaboration Skill
|
||||
|
||||
You are an expert document assistant helping the user create, edit, and refine documents in their knowledge base.
|
||||
|
||||
` + KNOWLEDGE_NOTE_STYLE_GUIDE + String.raw`
|
||||
|
||||
> The writing style above is non-negotiable for any content you author or edit in the knowledge base — even small one-off edits. The user's whole knowledge base is built on it. The rest of this skill covers the *workflow* of collaboration; the style guide above covers the *output*.
|
||||
|
||||
|
||||
## FIRST: Ask About Edit Mode
|
||||
|
||||
**Before doing anything else, ask the user:**
|
||||
|
|
@ -187,14 +194,14 @@ Displays an image with optional alt text and caption.
|
|||
- \`caption\` (optional): Caption displayed below the image
|
||||
|
||||
### Embed Block
|
||||
Embeds external content (YouTube videos, Figma designs, or generic links).
|
||||
Embeds external content (YouTube videos, Figma designs, tweets, or generic links).
|
||||
\`\`\`embed
|
||||
{"provider": "youtube", "url": "https://www.youtube.com/watch?v=VIDEO_ID", "caption": "Video title"}
|
||||
\`\`\`
|
||||
- \`provider\` (required): \`"youtube"\`, \`"figma"\`, or \`"generic"\`
|
||||
- \`provider\` (required): \`"youtube"\`, \`"figma"\`, \`"tweet"\`, or \`"generic"\`
|
||||
- \`url\` (required): Full URL to the content
|
||||
- \`caption\` (optional): Caption displayed below the embed
|
||||
- YouTube and Figma render as iframes; generic shows a link card
|
||||
- YouTube and Figma render as iframes; tweet renders inline from the tweet URL; generic shows a link card
|
||||
|
||||
### Iframe Block
|
||||
Embeds an arbitrary web page or a locally-served dashboard in the note.
|
||||
|
|
@ -237,10 +244,7 @@ Renders a styled table from structured data.
|
|||
|
||||
## Best Practices
|
||||
|
||||
**Writing style:**
|
||||
- Match the user's tone and style in the document
|
||||
- Be concise but complete
|
||||
- Use markdown formatting (headers, bullets, bold, etc.)
|
||||
**Writing style:** see "Knowledge-note writing style" at the top of this skill — that's the canonical guide. Match the user's tone for prose-shaped content (their own narrative writing); for everything else apply the terse-and-scannable rules.
|
||||
|
||||
**Editing:**
|
||||
- Make surgical edits - change only what's needed
|
||||
|
|
|
|||
|
|
@ -7,18 +7,20 @@ import draftEmailsSkill from "./draft-emails/skill.js";
|
|||
import mcpIntegrationSkill from "./mcp-integration/skill.js";
|
||||
import meetingPrepSkill from "./meeting-prep/skill.js";
|
||||
import organizeFilesSkill from "./organize-files/skill.js";
|
||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
|
||||
import appNavigationSkill from "./app-navigation/skill.js";
|
||||
import browserControlSkill from "./browser-control/skill.js";
|
||||
import codeWithAgentsSkill from "./code-with-agents/skill.js";
|
||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||
import tracksSkill from "./tracks/skill.js";
|
||||
import liveNoteSkill from "./live-note/skill.js";
|
||||
import backgroundTaskSkill from "./background-task/skill.js";
|
||||
import notifyUserSkill from "./notify-user/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
||||
// console.log(tracksSkill);
|
||||
// console.log(liveNoteSkill);
|
||||
|
||||
type SkillDefinition = {
|
||||
id: string; // Also used as folder name
|
||||
|
|
@ -64,12 +66,6 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
|
||||
content: organizeFilesSkill,
|
||||
},
|
||||
{
|
||||
id: "background-agents",
|
||||
title: "Background Agents",
|
||||
summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.",
|
||||
content: backgroundAgentsSkill,
|
||||
},
|
||||
{
|
||||
id: "builtin-tools",
|
||||
title: "Builtin Tools Reference",
|
||||
|
|
@ -101,10 +97,22 @@ const definitions: SkillDefinition[] = [
|
|||
content: appNavigationSkill,
|
||||
},
|
||||
{
|
||||
id: "tracks",
|
||||
title: "Tracks",
|
||||
summary: "Create and manage track blocks — YAML-scheduled auto-updating content blocks in notes (weather, news, prices, status, dashboards). Insert at cursor (Cmd+K) or append to notes.",
|
||||
content: tracksSkill,
|
||||
id: "code-with-agents",
|
||||
title: "Code with Agents",
|
||||
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
|
||||
content: codeWithAgentsSkill,
|
||||
},
|
||||
{
|
||||
id: "background-task",
|
||||
title: "Background Tasks",
|
||||
summary: "Set up a recurring background task — persistent instructions the agent fires on a schedule and/or on matching events (Gmail, Calendar). Either maintains an `index.md` digest (OUTPUT mode) or performs a recurring side-effect like drafting a reply / posting to Slack / calling an API (ACTION mode). Flagship surface for anything recurring.",
|
||||
content: backgroundTaskSkill,
|
||||
},
|
||||
{
|
||||
id: "live-note",
|
||||
title: "Live Notes",
|
||||
summary: "Make a specific markdown note self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule or on incoming events. Load only when the user explicitly says 'live note' / 'live-note'; for anything else recurring, prefer the background-task skill.",
|
||||
content: liveNoteSkill,
|
||||
},
|
||||
{
|
||||
id: "browser-control",
|
||||
|
|
@ -112,6 +120,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
|
||||
content: browserControlSkill,
|
||||
},
|
||||
{
|
||||
id: "notify-user",
|
||||
title: "Notify User",
|
||||
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
|
||||
content: notifyUserSkill,
|
||||
},
|
||||
];
|
||||
|
||||
const skillEntries = definitions.map((definition) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,639 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(LiveNoteSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The live-note agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, mention it in the objective so the agent doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render the leaderboard as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Plot the rate as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render the dependency map as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Show the agenda as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render the cover photo as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render the demo as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Embed the status page as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render the transcript as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output inside the objective and the live-note agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Live Notes Skill
|
||||
|
||||
A *live note* is a regular markdown note whose body is kept current by a background agent. The user expresses intent via a single \`live:\` block in the note's YAML frontmatter — one persistent **objective** plus an optional \`triggers\` object that says when the agent should fire (cron, time-of-day windows, and/or matching events). A note with no \`live:\` key is just static; adding one makes it live. Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor).
|
||||
|
||||
When this skill is loaded, your job is: make a passive note live (or extend the objective on an already-live note), run the agent once so the user immediately sees content, and tell them where to manage it.
|
||||
|
||||
## Mode: act-first (non-negotiable on strong signals)
|
||||
|
||||
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
|
||||
|
||||
What you must NOT do on a strong-signal ask:
|
||||
- Don't ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic doc editing, not live notes.
|
||||
- Don't ask "where should this live?" — pick a default folder (see below) and proceed.
|
||||
- Don't say "I'll create knowledge/Notes/X.md" without the action attached. Either say "Done — created…" or just do it.
|
||||
- Don't open with an explanation of what a live note is. The user already asked for one.
|
||||
- **Don't ask "should I do this?" — when the request is unambiguous, just do it.** A clarifying question is reserved for *genuine* ambiguity (see "When to ask one short question" below), not as a politeness gate.
|
||||
|
||||
If a previous skill or earlier turn was waiting on edit-mode permission, treat the live-note request as implicit "direct mode" and proceed.
|
||||
|
||||
The two **panel-driven** flows in "Exceptions" at the bottom of this skill are the only places where a first-turn explanation is wanted. Don't bleed that posture into normal asks.
|
||||
|
||||
## Reading the user's intent
|
||||
|
||||
You're loaded any time the user might be asking for something dynamic. Three postures, depending on signal strength:
|
||||
|
||||
### Strong signals — act, then confirm (default behaviour)
|
||||
|
||||
The user used unambiguous language asking for something to be tracked. **Just do it** — pick a default folder, look for an existing matching note, then either extend its objective or create a new live note. Run it once. Confirm in one line. No "should I?" gate.
|
||||
|
||||
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
|
||||
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
|
||||
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
|
||||
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
|
||||
- **Direct**: "set up a [feed / tracker / dashboard / live note] for X", "track X" / "make this live"
|
||||
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
|
||||
|
||||
### Default folder picker (when no note is named)
|
||||
|
||||
When a strong signal lands without a specific note attached, pick the folder by topic shape. Don't ask the user — pick.
|
||||
|
||||
| Topic shape | Default folder |
|
||||
|---|---|
|
||||
| News, headlines, market prices, weather, status pages, reference dashboards | \`knowledge/Notes/\` |
|
||||
| Tasks, monitors, daily briefings, recurring digests of the user's own data, "background agent"-style work | \`knowledge/Tasks/\` |
|
||||
| A specific person (e.g. "track everything about Sarah Chen") | \`knowledge/People/\` |
|
||||
| A specific company / org | \`knowledge/Organizations/\` |
|
||||
| A specific project or workstream | \`knowledge/Projects/\` |
|
||||
| A topic / theme | \`knowledge/Topics/\` |
|
||||
|
||||
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
|
||||
|
||||
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
|
||||
|
||||
### Default cadence picker (when the user didn't specify timing)
|
||||
|
||||
When the user names a topic but doesn't say *how often*, **pick a cadence** — don't ask. Use judgment based on the topic shape. The user can tweak it later in the panel.
|
||||
|
||||
| Topic shape | Default cadence |
|
||||
|---|---|
|
||||
| News / market summary / topic-following / weather / status | One morning **window** \`06:00\`–\`12:00\`. Add an \`eventMatchCriteria\` when the topic could also surface in synced Gmail/Calendar. |
|
||||
| Stock / crypto prices when the user says "real-time" or "throughout the day" | \`cronExpr\` hourly or every 15 min, depending on phrasing. |
|
||||
| Daily briefings / dashboards | Two or three **windows** spanning the workday (morning, midday, post-lunch). |
|
||||
| Email / calendar-driven topics (Q3 emails, customer reschedules) | \`eventMatchCriteria\` only — schedule is "when a relevant signal arrives". Add a single morning window if a fallback baseline refresh feels right. |
|
||||
|
||||
**When in doubt, default to a single morning window \`06:00\`–\`12:00\`.** It's forgiving (fires whenever the user opens the app in the morning) and matches the casual "I'll check this in the morning" expectation.
|
||||
|
||||
Reach for a precise \`cronExpr\` only when the user explicitly demands a clock time ("at 9am sharp", "every 15 minutes"). Casual asks ("every morning", "daily") get windows.
|
||||
|
||||
### When to ask one short question
|
||||
|
||||
Only when the request is **genuinely** ambiguous — not as a politeness gate. Examples:
|
||||
|
||||
- The user named a specific note that doesn't exist AND your search for similar names returned multiple plausible candidates → ask "Did you mean A or B?"
|
||||
- The new ask in an already-live note conflicts with the existing objective (replace, not extend) → ask "Replace the existing objective, or add this on top?"
|
||||
- The topic is too vague to derive a sensible filename or folder ("track stuff for me") → ask one focusing question.
|
||||
|
||||
Pick a single question, get to the action on the next turn. Never stack questions.
|
||||
|
||||
### Medium signals — answer the one-off, then offer
|
||||
|
||||
Answer the user's actual question first. Then add a single-line offer to keep it updated. **The offer is not optional on a medium signal — if you don't add it, you're failing the skill.** If the user says yes, make the note live. If they don't engage, leave it — don't push twice.
|
||||
|
||||
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
|
||||
- **News / updates on a topic**: "what's the latest news on Coinbase?", "what's happening with the Q3 launch?", "any updates on Project Apollo?", "what's new with [person/company]?"
|
||||
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" — especially when in a note context
|
||||
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
|
||||
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
|
||||
|
||||
**Catch-all heuristic:** if you reached for \`web-search\` or a news tool to answer a question about a person, company, project, or topic, the answer is exactly the kind of thing a live note would refresh on a schedule — **always offer** at the end. Same goes for any time-decaying lookup (prices, weather, status).
|
||||
|
||||
Offer line shape (one line, concrete):
|
||||
> "Want me to keep this in a live note that refreshes every morning?"
|
||||
|
||||
Or, when there's a sensible default file already implied (e.g. a topic name):
|
||||
> "I can drop this in \`knowledge/Notes/Coinbase News.md\` and refresh it every morning — want that?"
|
||||
|
||||
The offer goes at the **very end** of your response, on its own line, after the answer is fully delivered.
|
||||
|
||||
### Anti-signals — do NOT make a note live
|
||||
|
||||
- Definitional questions ("what is X?")
|
||||
- One-off lookups ("look up X for me")
|
||||
- Manual document work ("help me write…", "edit this paragraph…")
|
||||
- General how-to ("how do I do Y?")
|
||||
|
||||
## Already-live notes — extend, don't fork
|
||||
|
||||
**This is the most important rule of the skill.** When the user asks you to track something *new* in a note that **already has a \`live:\` block**, edit the existing \`objective\` in natural language to absorb the new ask. Do **not** create a second \`live:\` block. Do **not** introduce some other key. There is exactly one objective per note.
|
||||
|
||||
- The user says "also keep an eye on Hacker News stories about this" → read the current \`objective\`, append/integrate the new ask in natural-language prose, write it back.
|
||||
- The objective ends up longer over time. That's fine. The agent treats it as one coherent intent.
|
||||
- If the new ask conflicts with the old (e.g. user wants to *replace* what the note tracks), ask one short question to confirm before overwriting.
|
||||
|
||||
## What to say to the user
|
||||
|
||||
The user knows the feature as **live notes** and finds them in the **Live notes view**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "objective" in user-facing prose unless the user uses them first.
|
||||
|
||||
**Use past tense.** All of these messages are sent *after* the action — no future-tense "I'll do this" or "I'm going to set this up". The action already happened.
|
||||
|
||||
After making a passive note live (or creating a new live note from scratch):
|
||||
> Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view (Radio icon in the sidebar).
|
||||
|
||||
After extending the objective on an already-live note:
|
||||
> Updated the objective to also cover that. Re-running now so the new output shows up.
|
||||
|
||||
When skipping a re-run (because the user said not to or "later"):
|
||||
> Updated. I'll let it run on its next trigger.
|
||||
|
||||
**Anti-patterns** — don't write any of these:
|
||||
- "I'll set up a live note for you. Should I create knowledge/Notes/News Feed.md?" (future tense, asking permission)
|
||||
- "I need one thing to proceed: which note should this live in?" (asking when default-folder picker tells you the answer)
|
||||
- "That's a live note use case! Here's what I can set up: ..." (preamble + lecture instead of action)
|
||||
- "Here's a comprehensive setup..." or "I've prepared the following..." (decorative framing)
|
||||
|
||||
## Worked example — strong signal, no note named
|
||||
|
||||
**User:** "i want to set up a news feed to track news for India and the world."
|
||||
|
||||
**Right behaviour** (one turn):
|
||||
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
|
||||
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
|
||||
3. No match found → create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
|
||||
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
|
||||
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
|
||||
|
||||
**Wrong behaviour:** running 2 lookup tools, then surfacing a paragraph saying "That's a live note use case, so the clean setup is a self-updating news note with: India headlines, world headlines, a refresh cadence like every morning. I need one thing to proceed: which note should this live in? If you don't already have one, I'll create knowledge/Notes/News Feed.md and make it live there." The user already gave you everything you need. Act.
|
||||
|
||||
## What is a live note (concretely)
|
||||
|
||||
**Concrete example** — a note that shows the current Chicago time, refreshed hourly:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
Show the current time in Chicago, IL in 12-hour format. Keep it as one
|
||||
short line, no extra prose.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
---
|
||||
|
||||
# Chicago time
|
||||
|
||||
(empty — the agent will fill this in on the first run)
|
||||
` + "```" + `
|
||||
|
||||
After the first run, the body might become:
|
||||
|
||||
` + "```" + `markdown
|
||||
# Chicago time
|
||||
|
||||
2:30 PM, Central Time
|
||||
` + "```" + `
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
- News digests or headlines
|
||||
- Stock or crypto prices
|
||||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Living summaries fed by incoming events (emails, meeting notes)
|
||||
- Any recurring content that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
A live note lives entirely in the note's frontmatter — there is no inline marker in the body. The agent owns the entire body below the H1 and writes whatever content the objective demands.
|
||||
|
||||
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
<what this note should keep being>
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
---
|
||||
|
||||
# Note body
|
||||
` + "```" + `
|
||||
|
||||
A note has **at most one** \`live:\` block. Each block has exactly one \`objective\`. The objective can be long and cover several sub-topics — the agent reads it holistically. Omit \`triggers\` (or all three trigger fields) for a manual-only live note.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a \`live:\` block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
` + "```" + `
|
||||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
|
||||
|
||||
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for live-note runs; setting per-note values bypasses that and is almost always wrong.
|
||||
|
||||
The only time these belong on a note:
|
||||
|
||||
- The user **explicitly** named a model or provider for *this specific note* in their request ("use Claude Opus for this one", "force this onto OpenAI"). Quote the user's wording back when confirming.
|
||||
|
||||
Things that are **not** reasons to set these:
|
||||
|
||||
- "It should be fast" / "I want a small model" — that's a global preference, not a per-note one. Leave it; the global default exists.
|
||||
- "This note is complex" — write a clearer objective; don't reach for a different model.
|
||||
- "Just to be safe" / "in case it matters" — antipattern. Leave them out.
|
||||
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
|
||||
|
||||
## Writing a Good Objective
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Live-note output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to keep up to date, what to source from, and what shape the output should take.
|
||||
- **Multi-faceted is OK.** Unlike the old per-track model, a single objective can cover several related sub-topics — list them inside the objective text and let the agent organize the body. Don't fork a second objective.
|
||||
- **Imperative voice.** "Keep this note updated with…", "Show…", "Maintain a section titled…".
|
||||
- **Specify output shape when shape matters.** "One line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items", or pick a rich block (see "Rich block render" below).
|
||||
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
The objective runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"). The live-note agent only sees the objective — not this chat, not what it produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the note is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Per-trigger guidance (advanced)
|
||||
|
||||
**Default behaviour:** one objective serves all triggers — cron, window, event, and manual runs all see the same intent. **Don't reach for per-trigger branching unless the run actually needs to behave differently.**
|
||||
|
||||
The agent always receives a \`**Trigger:**\` line in its run message telling it which trigger fired:
|
||||
- \`Manual run (user-triggered)\` — Run button or Copilot tool.
|
||||
- \`Scheduled refresh — the cron expression \\\`<expr>\\\` matched\` — exact-time refresh.
|
||||
- \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh.
|
||||
- \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive.
|
||||
|
||||
**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window — pull a full snapshot from local data) and a *reactive* update (event — integrate one new signal). For example, an email digest can scan \`gmail_sync/\` for everything worth attention on a window run, then integrate one incoming thread on an event run without re-listing previously-seen threads. Same objective, two branches.
|
||||
|
||||
How to write it — use plain conditional language inside the objective:
|
||||
|
||||
\`\`\`yaml
|
||||
live:
|
||||
objective: |
|
||||
Maintain a digest of email threads worth attention today, as a single \`emails\` block.
|
||||
|
||||
Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the
|
||||
full digest from scratch.
|
||||
|
||||
With an event payload (event run): integrate the new thread into the existing digest —
|
||||
add it if new, update its entry if the threadId is already shown — and don't re-list
|
||||
threads the user has already seen unless their state changed.
|
||||
\`\`\`
|
||||
|
||||
Notice: the objective doesn't mention "cron" or "window" by name, just describes the conditions. The agent reads its \`**Trigger:**\` line and matches the right branch.
|
||||
|
||||
**Don't branch for stylistic reasons** ("on cron be terse, on event be verbose"). Branching is for *what data to look at* and *whether to do an incremental vs full update*, not for tone.
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **A second \`live:\` block** when one already exists — extend the existing objective instead.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
|
||||
## YAML String Style (critical — read before writing the ` + "`" + `objective` + "`" + ` or ` + "`" + `triggers.eventMatchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — \`objective\` and \`triggers.eventMatchCriteria\` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes \`lastRunAt\`, \`lastRunSummary\`, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the field gets truncated.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `objective` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.**
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
eventMatchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line.
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields:
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: "Show the current time in Chicago, IL in 12-hour format."
|
||||
active: true
|
||||
` + "```" + `
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
\`lastRunAt\`, \`lastRunId\`, \`lastRunSummary\` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Triggers
|
||||
|
||||
The \`triggers\` object has three optional sub-fields. Mix freely; presence of a field is the marker that the note should fire on that channel.
|
||||
|
||||
- \`cronExpr\` — fires at an exact recurring time (5-field cron string).
|
||||
- \`windows\` — list of \`{ startTime, endTime }\` bands; the agent fires once per day per window, anywhere inside the band.
|
||||
- \`eventMatchCriteria\` — natural-language description of which incoming events (emails, calendar changes) should wake the note.
|
||||
|
||||
Omit ` + "`" + `triggers` + "`" + ` entirely (or omit all three sub-fields) for a **manual-only** live note — the user runs it from the Run button in the panel.
|
||||
|
||||
### \`cronExpr\`
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Always quote the cron expression — it contains spaces and ` + "`" + `*` + "`" + `.
|
||||
|
||||
### \`windows\`
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
windows:
|
||||
- { startTime: "09:00", endTime: "12:00" }
|
||||
- { startTime: "13:00", endTime: "15:00" }
|
||||
` + "```" + `
|
||||
|
||||
Each window fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at \`startTime\` — once a fire lands at-or-after today's start, that window is done for the day. Use windows when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
|
||||
|
||||
### \`eventMatchCriteria\`
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How event triggering works:
|
||||
1. When a new event arrives, a fast LLM classifier checks each live note's \`eventMatchCriteria\` (and its objective) against the event content.
|
||||
2. If it might match, the live-note agent receives both the event payload and the existing note body, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
### Combining trigger fields
|
||||
|
||||
Mix freely. Example — a note that refreshes weekday mornings AND on incoming Q3 emails:
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: |
|
||||
Maintain a running summary of decisions and open questions about Q3 planning.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 9 * * 1-5"
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
- ` + "`" + `"*/15 * * * *"` + "`" + ` — every 15 minutes
|
||||
- ` + "`" + `"0 * * * *"` + "`" + ` — every hour on the hour
|
||||
- ` + "`" + `"0 8 * * *"` + "`" + ` — daily at 8am
|
||||
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` — weekdays at 9am
|
||||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Making a passive note live (no \`live:\` block yet)
|
||||
|
||||
1. \`workspace-readFile({ path })\` — re-read fresh.
|
||||
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
|
||||
3. \`workspace-edit\`:
|
||||
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
|
||||
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
|
||||
|
||||
### Extending an already-live note
|
||||
|
||||
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`.
|
||||
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
|
||||
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should this be in?"
|
||||
3. Apply the workflow above (extend if already live, create if passive).
|
||||
|
||||
### No note context at all
|
||||
|
||||
If the user used a strong signal but didn't name a specific note: **don't ask** "which note?" — use the Default folder picker (above) and proceed. Create the file with a sensible filename derived from the topic.
|
||||
|
||||
If the user used a medium signal with no note: answer the one-off, then offer to make it live somewhere (and pick the folder when they say yes).
|
||||
|
||||
## Exceptions — first-turn confirmation only when…
|
||||
|
||||
The two flows below are the **only** exceptions to the act-first default. They have explicit panel/card context that wants a brief explanation before the user commits. Don't bleed this posture into normal asks — outside these flows, strong signals get acted on, not explained.
|
||||
|
||||
### Exception 1: Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel with a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as \`knowledge/Topics/\` or \`knowledge/People/\`
|
||||
|
||||
This is a *browse* gesture, not a commit gesture — the user might back out. So:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the live note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it (extend objective if already live; make it live otherwise).
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The \`live:\` block should be the core of the note.
|
||||
|
||||
### Exception 2: New-live-note panel flow (panel-driven, no note named)
|
||||
|
||||
The user clicks the "New live note" button in the **Live notes** view and the opening message is the canned "I want to set up a Live note / task." (no specific topic, no note named). This is the only case where you ask before acting — but the ask is minimal.
|
||||
|
||||
On the first turn, reply with **just** a one-line prompt and 2-3 concrete examples. **Do not** explain what a live note is. **Do not** ask about cadence, folder, or format — you'll pick those yourself once they name a topic. Examples to draw from (pick 2-3 that span different shapes):
|
||||
|
||||
- A daily news feed for a topic ("AI coding agents", "India + world news")
|
||||
- A market summary ("BTC, ETH, SPY each morning")
|
||||
- A weekly Q3-emails digest from your inbox
|
||||
- A morning weather + commute-conditions briefing
|
||||
- A live dashboard for an ongoing project
|
||||
|
||||
Shape your reply roughly like:
|
||||
|
||||
> What would you like to track? A few examples to spark ideas:
|
||||
> - A daily news feed for a topic
|
||||
> - A market summary
|
||||
> - A digest of relevant emails
|
||||
|
||||
Once the user names a topic, **drop into the strong-signal flow**: use the Default folder picker for location, the Default cadence picker for timing, search for an existing match, extend or create, run once, confirm in one line. Don't bounce back with "great — and how often should it refresh?" — pick.
|
||||
|
||||
**The trigger for Exception 2 is specifically the generic "I want to set up a Live note / task." opening.** A user asking "set up a news feed for India and the world" is *not* in this flow — that's a strong signal, act on it.
|
||||
|
||||
## The Exact Frontmatter Shape
|
||||
|
||||
For a brand-new live note:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
<objective, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
---
|
||||
|
||||
# <Note title>
|
||||
` + "```" + `
|
||||
|
||||
**Rules:**
|
||||
- \`live:\` is at the top level of the frontmatter, never nested under other keys.
|
||||
- There is **at most one** \`live:\` block per note.
|
||||
- 2-space YAML indent throughout. No tabs.
|
||||
- \`triggers:\` is an object, not an array. Each sub-field (\`cronExpr\`, \`windows\`, \`eventMatchCriteria\`) is independently optional. Omit \`triggers\` entirely for manual-only.
|
||||
- **Always use the literal block scalar (\`|\`)** for \`objective\` and \`eventMatchCriteria\`.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and \`*\`.
|
||||
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The live-note agent edits the body on its first run.
|
||||
|
||||
## After Creating or Editing a Live Note
|
||||
|
||||
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the \`run-live-note-agent\` tool — same as the user clicking Run in the panel.
|
||||
|
||||
Why default-on:
|
||||
- For event-driven live notes (with \`eventMatchCriteria\`), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
|
||||
- For notes that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill \`context\` (see below) seeds rich initial content.
|
||||
- After an edit, the user expects to see the updated output without an extra round-trip.
|
||||
|
||||
Confirm in one line and tell the user where to find it:
|
||||
> "Done — this note is live, refreshing hourly. Running it once now so you see content right away. You can manage it from the Live Note panel."
|
||||
|
||||
For an objective extension on an already-live note:
|
||||
> "Updated the objective. Re-running now so you see the new output."
|
||||
|
||||
If you skipped the re-run (user said not to):
|
||||
> "Updated — I'll let it run on its next trigger."
|
||||
|
||||
**Do not** write content into the note body yourself — that's the live-note agent's job, delegated via \`run-live-note-agent\`.
|
||||
|
||||
## Using the \`run-live-note-agent\` tool
|
||||
|
||||
\`run-live-note-agent\` triggers a single run right now. You can pass an optional \`context\` string to bias *this run only* without modifying the objective — the difference between a stock refresh and a smart backfill.
|
||||
|
||||
### Backfill \`context\` examples
|
||||
|
||||
- A newly-live note watching Q3 emails → run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
|
||||
- A new note tracking this week's customer calls → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
- Plain refresh (user said "run it now"): **omit \`context\`**. Don't invent it.
|
||||
|
||||
### Reading the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- \`action: 'replace'\` → body changed. Confirm in one line; optionally cite the first line of \`contentAfter\`.
|
||||
- \`action: 'no_update'\` → agent decided nothing needed to change. Tell the user briefly; \`summary\` usually explains why.
|
||||
- \`error: 'Already running'\` → another run is in flight; tell the user to retry shortly.
|
||||
- Other \`error\` → surface concisely.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't run more than once** per user-facing action — one tool call per turn.
|
||||
- **Don't pass \`context\`** for a plain refresh — it can mislead the agent.
|
||||
- **Don't write content into the note body yourself** — always delegate via \`run-live-note-agent\`.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't create a second \`live:\` block** when one already exists — extend the existing \`objective\`.
|
||||
- **Don't add \`triggers\`** if the user explicitly wants manual-only.
|
||||
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Live Note
|
||||
|
||||
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar).
|
||||
|
||||
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object.
|
||||
|
||||
**Pause without removing:** flip \`active: false\`.
|
||||
|
||||
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template (frontmatter only):
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: |
|
||||
<objective — always use \`|\`, indented 2 spaces>
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Top cron expressions: \`"0 * * * *"\` (hourly), \`"0 8 * * *"\` (daily 8am), \`"0 9 * * 1-5"\` (weekdays 9am), \`"*/15 * * * *"\` (every 15m).
|
||||
|
||||
YAML style reminder: \`objective\` and \`eventMatchCriteria\` are **always** \`|\` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
export const skill = String.raw`
|
||||
# Notify User
|
||||
|
||||
Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track detects something noteworthy, or when an agent wants to ping the user with a clickable result.
|
||||
|
||||
## When to use
|
||||
- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive.
|
||||
- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit — restraint is on you).
|
||||
|
||||
## The tool: \`notify-user\`
|
||||
|
||||
Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click.
|
||||
|
||||
### Parameters
|
||||
- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top.
|
||||
- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines.
|
||||
- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted:
|
||||
- **\`https://...\` / \`http://...\`** — opens in the default browser
|
||||
- **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below)
|
||||
- If omitted, clicking the notification focuses the Rowboat app.
|
||||
|
||||
### Examples
|
||||
|
||||
Plain alert (no link — clicking focuses the app):
|
||||
\`\`\`json
|
||||
{
|
||||
"title": "Backup complete",
|
||||
"message": "All 142 files synced to iCloud."
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
External link:
|
||||
\`\`\`json
|
||||
{
|
||||
"title": "New email from Monica",
|
||||
"message": "Re: Q4 planning — needs your input by Friday",
|
||||
"link": "https://mail.google.com/mail/u/0/#inbox/abc123"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Deep link into a Rowboat note:
|
||||
\`\`\`json
|
||||
{
|
||||
"message": "Daily brief is ready",
|
||||
"link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Deep links: \`rowboat://\`
|
||||
|
||||
Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters.
|
||||
|
||||
| Target | Format | Example |
|
||||
|---|---|---|
|
||||
| Open a file | \`rowboat://open?type=file&path=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` |
|
||||
| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` |
|
||||
| Knowledge graph | \`rowboat://open?type=graph\` | — |
|
||||
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
|
||||
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
|
||||
|
||||
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
|
||||
|
||||
## Anti-patterns
|
||||
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
|
||||
- **Don't repeat what's already on screen.** If the result is already in the chat or in a note the user is viewing, skip the notification.
|
||||
- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link.
|
||||
- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done".
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The track agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Tracks Skill
|
||||
|
||||
You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
|
||||
|
||||
## First: Just Do It — Do Not Ask About Edit Mode
|
||||
|
||||
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
|
||||
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
|
||||
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
|
||||
- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact.
|
||||
|
||||
## What Is a Track Block
|
||||
|
||||
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
|
||||
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
|
||||
- A sibling "target region" — an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
|
||||
|
||||
**Concrete example** (a track that shows the current time in Chicago every hour):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: chicago-time
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
<!--/track-target:chicago-time-->
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
- News digests or headlines
|
||||
- Stock or crypto prices
|
||||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Any recurring summary that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
Each track has two parts that live next to each other in the note:
|
||||
|
||||
1. The ` + "`" + `track` + "`" + ` code fence — contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
|
||||
2. The target-comment region — ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
|
||||
|
||||
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a track block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
` + "```" + `
|
||||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Choosing a trackId
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
- **Must be unique within the note file.** Before inserting, read the file and check:
|
||||
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
|
||||
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
|
||||
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
|
||||
- Don't reuse an old ID even if the previous block was deleted — pick a fresh one.
|
||||
|
||||
## Writing a Good Instruction
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to fetch or compute.
|
||||
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
|
||||
- **Imperative voice, 1-3 sentences.**
|
||||
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
|
||||
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction — not this chat, not what you produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the user is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **Bundling multiple purposes** into one instruction — split into separate track blocks.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
|
||||
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
Note: when a location is in DST, reflect that in the offset column.
|
||||
eventMatchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line.
|
||||
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
|
||||
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields with no newline needs:
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: "Show the current time in Chicago, IL in 12-hour format."
|
||||
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
|
||||
` + "```" + `
|
||||
|
||||
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
|
||||
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
|
||||
|
||||
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: 'He said "hi" at 9:00.'
|
||||
` + "```" + `
|
||||
|
||||
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
|
||||
- No other escape sequences work.
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not.
|
||||
|
||||
### Editing an existing track
|
||||
|
||||
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Schedules
|
||||
|
||||
Schedule is an **optional** discriminated union. Three types:
|
||||
|
||||
### ` + "`" + `cron` + "`" + ` — recurring at exact times
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
|
||||
|
||||
### ` + "`" + `window` + "`" + ` — recurring within a time-of-day range
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: window
|
||||
cron: "0 0 * * 1-5"
|
||||
startTime: "09:00"
|
||||
endTime: "17:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `–` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" — flexible timing with bounds.
|
||||
|
||||
### ` + "`" + `once` + "`" + ` — one-shot at a future time
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: once
|
||||
runAt: "2026-04-14T09:00:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
- ` + "`" + `"*/15 * * * *"` + "`" + ` — every 15 minutes
|
||||
- ` + "`" + `"0 * * * *"` + "`" + ` — every hour on the hour
|
||||
- ` + "`" + `"0 8 * * *"` + "`" + ` — daily at 8am
|
||||
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` — weekdays at 9am
|
||||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** — the user triggers it via the Play button in the UI.
|
||||
|
||||
## Event Triggers (third trigger type)
|
||||
|
||||
In addition to manual and scheduled, a track can be triggered by **events** — incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: q3-planning-emails
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3
|
||||
planning, drawn from emails on the topic.
|
||||
active: true
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How it works:
|
||||
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
|
||||
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
When to suggest event triggers:
|
||||
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
|
||||
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
|
||||
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined — a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
|
||||
|
||||
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
|
||||
- Be descriptive but not overly narrow — Pass 1 routing is liberal by design.
|
||||
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
|
||||
|
||||
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely — they'll only run on schedule or manually.
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Cmd+K with cursor context
|
||||
|
||||
When the user invokes Cmd+K, the context includes an attachment mention like:
|
||||
> User has attached the following files:
|
||||
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
|
||||
|
||||
Workflow:
|
||||
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
|
||||
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` — always re-read fresh.
|
||||
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
|
||||
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
|
||||
5. Construct the full track block (YAML + target pair).
|
||||
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should I add the track to?"
|
||||
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
|
||||
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
|
||||
|
||||
### No note context at all
|
||||
|
||||
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
|
||||
|
||||
### Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
|
||||
|
||||
## The Exact Text to Insert
|
||||
|
||||
Write it verbatim like this (including the blank line between fence and target):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <id>
|
||||
instruction: |
|
||||
<instruction, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<id>-->
|
||||
<!--/track-target:<id>-->
|
||||
|
||||
**Rules:**
|
||||
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
|
||||
- Target pair is **empty on creation**. The runner fills it on the first run.
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `.
|
||||
- Use 2-space YAML indent. No tabs.
|
||||
- Top-level markdown only — never inside a code fence, blockquote, or table.
|
||||
|
||||
## After Insertion
|
||||
|
||||
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
|
||||
- **Then offer to run it once now** (see "Running a Track" below) — especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
|
||||
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself — use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
|
||||
|
||||
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
|
||||
|
||||
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button — but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
|
||||
|
||||
### When to proactively offer to run
|
||||
|
||||
These are upsells — ask first, don't run silently.
|
||||
|
||||
- **Just created a new track block.** Before declaring done, offer:
|
||||
> "Want me to run it once now to seed the initial content?"
|
||||
|
||||
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) — otherwise the target region stays empty until the next matching event arrives.
|
||||
|
||||
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
|
||||
|
||||
- **Just edited an existing track.** Offer:
|
||||
> "Want me to run it now to see the updated output?"
|
||||
|
||||
- **Explicit user request.** "run the X track", "test it", "refresh that block" → call the tool directly.
|
||||
|
||||
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
|
||||
|
||||
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- New track: "Track emails about Q3 planning" → after creating it, run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
|
||||
|
||||
- New track: "Summarize this week's customer calls" → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
|
||||
- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context — it can mislead the agent.
|
||||
|
||||
### What to do with the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- **` + "`" + `action: 'replace'` + "`" + `** → the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
|
||||
> "Done — track now shows: 72°F, partly cloudy in Chicago."
|
||||
|
||||
- **` + "`" + `action: 'no_update'` + "`" + `** → the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
|
||||
|
||||
- **` + "`" + `error` + "`" + ` set** → surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't auto-run** after every edit — ask first.
|
||||
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
|
||||
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
|
||||
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn — one run per user-facing action.
|
||||
|
||||
## Proactive Suggestions
|
||||
|
||||
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
|
||||
- "I want to track / monitor / watch / keep an eye on / follow X"
|
||||
- "Can you check on X every morning / hourly / weekly?"
|
||||
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
|
||||
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
|
||||
|
||||
Suggestion style — one line, concrete:
|
||||
> "I can turn this into a track block that refreshes hourly — want that?"
|
||||
|
||||
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
|
||||
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
|
||||
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` — runtime-managed.
|
||||
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` — local time only.
|
||||
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file — always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Track
|
||||
|
||||
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
|
||||
|
||||
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
|
||||
|
||||
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <kebab-id>
|
||||
instruction: |
|
||||
<what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<kebab-id>-->
|
||||
<!--/track-target:<kebab-id>-->
|
||||
|
||||
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
|
||||
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { ensureLoaded, readSkillContent, refreshFromRemote } from './loader.js';
|
||||
export type { SkillEntry, SkillsIndex, LoaderStatus } from './loader.js';
|
||||
export { matchSkillsForUrl } from './matcher.js';
|
||||
215
apps/x/packages/core/src/application/browser-skills/loader.ts
Normal file
215
apps/x/packages/core/src/application/browser-skills/loader.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
|
||||
const REPO_OWNER = 'browser-use';
|
||||
const REPO_NAME = 'browser-harness';
|
||||
const REPO_BRANCH = 'main';
|
||||
const DOMAIN_SKILLS_PREFIX = 'domain-skills/';
|
||||
|
||||
const MANIFEST_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
export type SkillEntry = {
|
||||
id: string; // e.g. "github/repo-actions"
|
||||
site: string; // e.g. "github"
|
||||
fileName: string; // e.g. "repo-actions.md"
|
||||
title: string; // first H1 from the markdown, or a derived title
|
||||
path: string; // relative repo path, e.g. "domain-skills/github/repo-actions.md"
|
||||
localPath: string; // absolute path on disk
|
||||
};
|
||||
|
||||
export type SkillsIndex = {
|
||||
fetchedAt: number;
|
||||
treeSha: string;
|
||||
entries: SkillEntry[];
|
||||
};
|
||||
|
||||
export type LoaderStatus =
|
||||
| { status: 'ready'; index: SkillsIndex }
|
||||
| { status: 'stale'; index: SkillsIndex; refreshing: boolean }
|
||||
| { status: 'empty' }
|
||||
| { status: 'error'; error: string };
|
||||
|
||||
const cacheRoot = () => path.join(WorkDir, 'cache', 'browser-skills');
|
||||
const skillsDir = () => path.join(cacheRoot(), 'domain-skills');
|
||||
const manifestPath = () => path.join(cacheRoot(), 'manifest.json');
|
||||
|
||||
async function ensureCacheDir(): Promise<void> {
|
||||
await fs.mkdir(skillsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
async function readManifest(): Promise<SkillsIndex | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as SkillsIndex;
|
||||
if (!parsed.entries || !Array.isArray(parsed.entries)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(index: SkillsIndex): Promise<void> {
|
||||
await ensureCacheDir();
|
||||
await fs.writeFile(manifestPath(), JSON.stringify(index, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function extractTitle(markdown: string, fallback: string): string {
|
||||
const match = markdown.match(/^#\s+(.+?)\s*$/m);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'rowboat-browser-skills',
|
||||
Accept: 'application/vnd.github+json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type GithubTreeNode = { path: string; type: string; sha: string };
|
||||
|
||||
async function fetchRepoTree(): Promise<{ treeSha: string; skillPaths: string[] }> {
|
||||
const branchUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches/${REPO_BRANCH}`;
|
||||
const branchRes = await fetchWithTimeout(branchUrl);
|
||||
if (!branchRes.ok) {
|
||||
throw new Error(`GitHub branch fetch failed: ${branchRes.status} ${branchRes.statusText}`);
|
||||
}
|
||||
const branch = (await branchRes.json()) as { commit: { commit: { tree: { sha: string } } } };
|
||||
const treeSha = branch.commit?.commit?.tree?.sha;
|
||||
if (!treeSha) throw new Error('Could not resolve tree SHA from branch response.');
|
||||
|
||||
const treeUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/${treeSha}?recursive=1`;
|
||||
const treeRes = await fetchWithTimeout(treeUrl);
|
||||
if (!treeRes.ok) {
|
||||
throw new Error(`GitHub tree fetch failed: ${treeRes.status} ${treeRes.statusText}`);
|
||||
}
|
||||
const tree = (await treeRes.json()) as { tree: GithubTreeNode[]; truncated: boolean };
|
||||
|
||||
const skillPaths = tree.tree
|
||||
.filter((n) => n.type === 'blob' && n.path.startsWith(DOMAIN_SKILLS_PREFIX) && n.path.endsWith('.md'))
|
||||
.map((n) => n.path);
|
||||
|
||||
return { treeSha, skillPaths };
|
||||
}
|
||||
|
||||
async function fetchRawFile(repoPath: string): Promise<string> {
|
||||
const url = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}/${repoPath}`;
|
||||
const res = await fetchWithTimeout(url, { headers: { Accept: 'text/plain' } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Raw file fetch failed for ${repoPath}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function parseRepoPath(repoPath: string): { id: string; site: string; fileName: string } | null {
|
||||
const rel = repoPath.slice(DOMAIN_SKILLS_PREFIX.length);
|
||||
const parts = rel.split('/');
|
||||
if (parts.length < 2) return null;
|
||||
const site = parts[0];
|
||||
const fileName = parts.slice(1).join('/');
|
||||
const id = rel.replace(/\.md$/, '');
|
||||
return { id, site, fileName };
|
||||
}
|
||||
|
||||
export async function refreshFromRemote(): Promise<SkillsIndex> {
|
||||
await ensureCacheDir();
|
||||
const { treeSha, skillPaths } = await fetchRepoTree();
|
||||
|
||||
const entries: SkillEntry[] = [];
|
||||
await Promise.all(skillPaths.map(async (repoPath) => {
|
||||
const parsed = parseRepoPath(repoPath);
|
||||
if (!parsed) return;
|
||||
try {
|
||||
const content = await fetchRawFile(repoPath);
|
||||
const localRel = path.join(parsed.site, parsed.fileName);
|
||||
const localPath = path.join(skillsDir(), localRel);
|
||||
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
||||
await fs.writeFile(localPath, content, 'utf8');
|
||||
entries.push({
|
||||
id: parsed.id,
|
||||
site: parsed.site,
|
||||
fileName: parsed.fileName,
|
||||
title: extractTitle(content, parsed.id),
|
||||
path: repoPath,
|
||||
localPath,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[browser-skills] Failed to fetch ${repoPath}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
entries.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const index: SkillsIndex = {
|
||||
fetchedAt: Date.now(),
|
||||
treeSha,
|
||||
entries,
|
||||
};
|
||||
await writeManifest(index);
|
||||
return index;
|
||||
}
|
||||
|
||||
let inFlightRefresh: Promise<SkillsIndex> | null = null;
|
||||
|
||||
export async function ensureLoaded(options?: { forceRefresh?: boolean }): Promise<LoaderStatus> {
|
||||
try {
|
||||
const existing = await readManifest();
|
||||
const fresh = existing && Date.now() - existing.fetchedAt < MANIFEST_TTL_MS;
|
||||
|
||||
if (existing && fresh && !options?.forceRefresh) {
|
||||
return { status: 'ready', index: existing };
|
||||
}
|
||||
|
||||
if (existing && !options?.forceRefresh) {
|
||||
if (!inFlightRefresh) {
|
||||
inFlightRefresh = refreshFromRemote()
|
||||
.catch((err) => {
|
||||
console.warn('[browser-skills] Background refresh failed:', err);
|
||||
return existing;
|
||||
})
|
||||
.finally(() => { inFlightRefresh = null; });
|
||||
}
|
||||
return { status: 'stale', index: existing, refreshing: true };
|
||||
}
|
||||
|
||||
if (!inFlightRefresh) {
|
||||
inFlightRefresh = refreshFromRemote().finally(() => { inFlightRefresh = null; });
|
||||
}
|
||||
try {
|
||||
const index = await inFlightRefresh;
|
||||
return { status: 'ready', index };
|
||||
} catch (err) {
|
||||
return { status: 'error', error: err instanceof Error ? err.message : 'Failed to load skills.' };
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'error', error: err instanceof Error ? err.message : 'Skill loader failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSkillContent(id: string): Promise<{ ok: true; content: string; entry: SkillEntry } | { ok: false; error: string }> {
|
||||
const status = await ensureLoaded();
|
||||
if (status.status === 'error' || status.status === 'empty') {
|
||||
return { ok: false, error: status.status === 'error' ? status.error : 'No skills cached yet.' };
|
||||
}
|
||||
const entry = status.index.entries.find((e) => e.id === id);
|
||||
if (!entry) return { ok: false, error: `Skill '${id}' not found.` };
|
||||
try {
|
||||
const content = await fs.readFile(entry.localPath, 'utf8');
|
||||
return { ok: true, content, entry };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Failed to read skill file.' };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { SkillEntry, SkillsIndex } from './loader.js';
|
||||
|
||||
/**
|
||||
* Map browser-harness `domain-skills/<site>/` folder names to hostname tokens we
|
||||
* match against the current tab's URL.
|
||||
*
|
||||
* Heuristic: for each site folder we generate candidate hostnames like
|
||||
* "booking-com" -> ["booking-com", "bookingcom", "booking.com"]
|
||||
* "github" -> ["github", "github.com"]
|
||||
* "dev-to" -> ["dev-to", "devto", "dev.to"]
|
||||
* Then we check whether any candidate is a substring of the tab hostname.
|
||||
*/
|
||||
function siteCandidates(site: string): string[] {
|
||||
const candidates = new Set<string>();
|
||||
candidates.add(site);
|
||||
candidates.add(site.replace(/-/g, ''));
|
||||
candidates.add(site.replace(/-/g, '.'));
|
||||
if (site.endsWith('-com')) {
|
||||
candidates.add(`${site.slice(0, -4)}.com`);
|
||||
}
|
||||
if (site.endsWith('-org')) {
|
||||
candidates.add(`${site.slice(0, -4)}.org`);
|
||||
}
|
||||
if (site.endsWith('-io')) {
|
||||
candidates.add(`${site.slice(0, -3)}.io`);
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function extractHostname(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchSkillsForUrl(index: SkillsIndex, url: string, limit = 5): SkillEntry[] {
|
||||
const hostname = extractHostname(url);
|
||||
if (!hostname) return [];
|
||||
|
||||
const bySite = new Map<string, SkillEntry[]>();
|
||||
for (const entry of index.entries) {
|
||||
if (!bySite.has(entry.site)) bySite.set(entry.site, []);
|
||||
bySite.get(entry.site)!.push(entry);
|
||||
}
|
||||
|
||||
const matched: SkillEntry[] = [];
|
||||
for (const [site, entries] of bySite) {
|
||||
const candidates = siteCandidates(site);
|
||||
const hit = candidates.some((c) => hostname === c || hostname.endsWith(`.${c}`) || hostname.includes(c));
|
||||
if (hit) matched.push(...entries);
|
||||
}
|
||||
|
||||
return matched.slice(0, limit);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue