Compare commits

...

99 commits
v0.3.0 ... main

Author SHA1 Message Date
Ramnique Singh
6288f99a85
Merge pull request #581 from rowboatlabs/chat-log
Add chat log download menu
2026-05-27 23:18:46 +05:30
Ramnique Singh
2f9ce051c0 Add chat log download menu 2026-05-27 23:17:47 +05:30
arkml
89f6f80215
Merge pull request #575 from rowboatlabs/update-kg-live-note-models
moved from preview models
2026-05-25 22:48:37 +05:30
Arjun
6d953428c4 moved from preview models 2026-05-25 22:43:45 +05:30
Ramnique Singh
c4888e2899
Merge pull request #566 from rowboatlabs/dev
send LLM use-case metadata through Rowboat gateway
2026-05-20 07:11:50 +05:30
Ramnique Singh
ec2e7d8145 send LLM use-case metadata through Rowboat gateway
Attach the current analytics use-case context to Rowboat gateway requests so backend billing generation rows can capture use_case, sub_use_case, and agent_name.

Wrap streamed agent calls and direct instrumented LLM call sites in explicit use-case context to keep metadata available when provider requests are created.
2026-05-20 07:11:06 +05:30
arkml
aba65843c2
Merge pull request #565 from rowboatlabs/dev
Dev
2026-05-19 21:35:00 +05:30
Arjun
55490fa63c make email tab backwards compatible 2026-05-19 21:30:03 +05:30
Arjun
9ee42d2f75 change default live note model 2026-05-19 20:55:14 +05:30
Ramnique Singh
95c313de89 Deprecate generated Today.md live note 2026-05-19 15:13:05 +05:30
Ramnique Singh
fe5e67f810 Render background task output with rich markdown
Add a read-only TipTap-backed RichMarkdownViewer and use it for Background Tasks output so rendered index.md files can display the same rich fenced blocks as notes, including email, calendar, chart, table, image, embed, transcript, and Mermaid blocks.

Keep the existing Source/Rendered toggle for raw markdown inspection, and hide editor-only delete controls in read-only output.

Move the rich block format examples out of the LiveNote-only prompt and into the shared knowledge note style guide. This gives both LiveNote and Background Task agents the same canonical renderer contract, including exact fenced-code schemas for rich Markdown blocks and the rule to avoid emitting task blocks as agent output.

Verified with:
- npm run build in apps/x/apps/renderer
- npm run build in apps/x/packages/core
2026-05-19 09:59:01 +05:30
Ramnique Singh
65f8e9d678 Merge branch 'main' into dev 2026-05-19 09:31:51 +05:30
arkml
4d160da105
minor design changes (#564) 2026-05-18 22:49:18 +05:30
arkml
6492cf65b5
meetings page (#558)
* move meetings to own page

* show calendar
2026-05-18 22:06:04 +05:30
arkml
7dcf8eea70
Email page (#561)
* email view

* render html emails

* match unread and read status

* move to accordian

* faster loads

* iframe mounted across toggle and cached height

* prefetch on hover

* fix iframe caching

* split inbox

* email processing agent

* summary

* rich text

* email drafts

* add pagination, watcher and separation from gmail sync

* fix first load issue

* handle drafts

* send button opens the thread

* simplify renderer and fix flickering issue

* remove rended driven email path

* support attachments in incoming emails

* fix white background as well as dark mode
2026-05-18 21:46:26 +05:30
Ramnique Singh
69e4f253dd
Merge pull request #563 from rowboatlabs/dev
Dev
2026-05-18 17:12:30 +05:30
Ramnique Singh
af618155e1 Update Electron billing UI for free plan 2026-05-18 11:12:39 +05:30
Arjun
d586f6bd8a fix calendar sync issue 2026-05-16 18:18:54 +05:30
Arjun
f9ddc6549a add show in finder 2026-05-15 12:11:50 +05:30
Arjun
41f783d504 dev instance skips lock 2026-05-14 22:06:19 +05:30
Arjun
f371cd4bb1 ignore spam and trash emails 2026-05-13 14:05:52 +05:30
Ramnique Singh
b01af12148 feat: background tasks
Adds Background Tasks — recurring background agents the user can set up to
either keep a digest current (daily email summary, top HN stories, weather
brief) or perform a recurring action (draft a reply, post to Slack, call an
API). Each task is a persistent set of instructions plus optional triggers
(schedule, time-of-day window, or matching incoming Gmail / calendar event).
The agent reads the verbs in the instructions on every run and picks the
right mode automatically.

User-facing surfaces:
- New "Background tasks" entry in the sidebar, with a table listing every
  task, its schedule, last run, and an active toggle.
- A detail page per task with a max-width reader showing the task's
  current output and a control sidebar for editing instructions, triggers,
  and reviewing run history.
- "New task" can open in a free-form box where the user describes what they
  want and Copilot sets it up end-to-end, or in a structured form for
  manual setup.
- "Edit with Copilot" hand-off from the detail view, pre-seeded with the
  task's context.

Under the hood:
- The event pipeline that previously powered live-notes is now a generic
  consumer registry. Live-notes and background tasks both subscribe;
  incoming events are routed to candidates from both concurrently.
- Schedule helpers and the agent-message trigger block are factored out of
  live-notes into shared modules. Both features use the same building
  blocks now.
- Copilot's proactive routing is reframed: anything recurring (cadence
  words, watch / monitor verbs, action verbs, event-conditional asks) now
  flows to background tasks. Live-notes load only on explicit mention.
- A small reliability fix for the run-creation fallback chain: an
  empty-string model/provider passed by an LLM tool call now correctly
  falls through to the default instead of being persisted as a real value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:43:25 +05:30
Ramnique Singh
13fa80c687 Merge branch 'main' into dev 2026-05-12 11:30:42 +05:30
gagan
e594b667bf
fix: resolve claude.exe for acpx on windows to dodge spawn einval (#554) 2026-05-12 00:57:53 +05:30
arkml
c756e61d7a
Merge pull request #553 from rowboatlabs/dev
note creation uses kg model
2026-05-12 00:22:32 +05:30
Arjun
47d7100368 note creation uses kg model 2026-05-12 00:18:48 +05:30
Ramnique Singh
a9b4e06018
Merge pull request #552 from rowboatlabs/dev
Dev
2026-05-11 15:34:15 +05:30
Ramnique Singh
ab23cb4543 feat: redesign live-note sidebar with Objective / Last run / Details tabs
Flatten the panel to match the rest of the app's design language. Splits
the surface into three tabs:

- Objective: full-height markdown render of the objective, in-tab plain
  monospace editor (no card-in-card chrome).
- Last run: fetches via `runs:fetch` and shows the agent's full
  transcript — summary at top, then a compact chat of user/assistant
  turns with collapsible tool calls (Parameters/Result).
- Details: triggers (single cron + windows + events with display/edit
  toggle) and collapsed Advanced (model/provider/danger zone) ending in
  "Convert to static note →".

Adds a 2-column status strip (Last run · Triggers) above the tabs and a
context-aware footer. Adopts the app's signature `uppercase tracking-wider
text-muted-foreground` label style; drops nested bordered cards.

New helper `lib/run-to-conversation.ts` converts `Run.log` events into
ConversationItems for read-only playback — adapted from App.tsx's live
converter, trimmed for static history (no streaming/permission flows,
skips lifecycle and system/tool-role messages).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-11 15:33:19 +05:30
Arjun
e3d2a0988b embed tweets 2026-05-09 12:06:54 +05:30
Ramnique Singh
10995ebed6
Merge pull request #542 from rowboatlabs/dev
fix: resolve TS errors for unused fileContent state and missing JSX n…
2026-05-09 00:43:16 +05:30
Ramnique Singh
8737605666 fix: resolve TS errors for unused fileContent state and missing JSX namespace
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:42:03 +05:30
Ramnique Singh
b7b84e94e0
Merge pull request #541 from rowboatlabs/dev
Dev
2026-05-09 00:34:29 +05:30
Ramnique Singh
dabca3da19 feat: live notes — single objective per note replaces multi-track model
Folds the multi-`track:`-array model into one `live:` block per note: a single
persistent objective the live-note agent maintains, plus an optional triggers
object (`cronExpr` / `windows` / `eventMatchCriteria`, each independently
optional). A note is now passive or live — no per-track scopes, no section
ownership contract, no `once` trigger. The agent owns the whole body and makes
patch-style incremental edits per run.

Highlights:
- Schema: `track:` array → single `live:` object (`packages/shared/src/live-note.ts`).
- Runtime: scheduler / event processor / runner under `core/knowledge/live-note/`,
  with split `lastAttemptAt` (every run, drives 5-min backoff) vs `lastRunAt`
  (success only, anchors cycles). `throwOnError` on agent runs surfaces LLM /
  billing failures into `lastRunError`.
- Today.md: regenerated by template v2 (single objective covering overview /
  calendar / emails / what-you-missed / priorities; existing files renamed to
  `Today.md.bkp.<stamp>`).
- Renderer: `LiveNoteSidebar` mounts inside the editor row (no chat overlap,
  auto-closes on note switch); toolbar Radio button becomes a status pill;
  `LiveNotesView` replaces background-agents view.
- Copilot: new `live-note` skill with act-first stance, default folder/cadence
  pickers, and a non-negotiable rule to extend an existing objective rather
  than add a second one. Shared `KNOWLEDGE_NOTE_STYLE_GUIDE` enforces
  terse-and-scannable writing across `doc-collab` and the live-note agent.
- Analytics: `track_block` use-case → `live_note_agent`; trigger
  (`manual` / `cron` / `window` / `event`) becomes the Pass-2 sub-use-case,
  alongside `routing` for Pass 1. Legacy run files with the old value are
  read-mapped via `LegacyStartEvent` so they stay openable in the runs list.

Hard cutover — no back-compat shims for legacy `track:` frontmatter arrays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:30:43 +05:30
Ramnique Singh
0bf7a55611
Merge pull request #539 from rowboatlabs/feat/knowledge-file-viewer
feat: render html, image, video, audio, and pdf in knowledge view
2026-05-09 00:30:14 +05:30
Arjun
cc176898df fix onboarding tip 2026-05-08 18:10:26 +05:30
Gagancreates
66e22bd779 chore: ignore test-fixtures dir 2026-05-08 17:04:25 +05:30
Gagancreates
8e10d8bff3 chore: stop tracking test fixture 2026-05-08 17:03:58 +05:30
Gagancreates
c5ee363122 feat: show unsupported file panel instead of raw bytes 2026-05-08 16:55:12 +05:30
Gagancreates
89f56a8059 docs: note srcdoc relative-asset limitation in html viewer 2026-05-08 16:46:22 +05:30
Gagancreates
385ed3377f refactor: extract getViewerType helper to share extension list 2026-05-08 16:45:47 +05:30
Gagancreates
60e5b2cbc7 chore: drop planning docs from repo 2026-05-08 16:45:37 +05:30
Ramnique Singh
a1e4002533
Merge pull request #540 from rowboatlabs/dev
Dev
2026-05-08 13:21:51 +05:30
Ramnique Singh
3b09296291 fix: route Google reconnect through rowboat flow when signed in
The Reconnect button on the Google account row always opened the BYOK
client-ID modal, even for users signed into Rowboat — who should get
the managed-credentials browser flow instead. The non-reconnect Connect
button already branched correctly via useConnectors.handleConnect; the
reconnect path bypassed it. Adds a handleReconnect helper that mirrors
the same branching, and routes both call sites (popover and settings)
through it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:21:05 +05:30
Ramnique Singh
acff502f42 fix: stop Gmail sync from throwing "No refresh token is set" in rowboat mode
In rowboat OAuth mode the OAuth2Client is built without a refresh_token
because refreshes go through the api. google-auth-library's default
5-minute eagerRefreshThresholdMillis caused it to attempt a refresh
whenever a Gmail call landed within 5 minutes of token expiry, throwing
"No refresh token is set." before our proactive 60s-margin refresh
could run. Disabling the eager window lets our getClient() refresh path
own all refreshes as the comment intends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:21:05 +05:30
Ramnique Singh
f5bba5e271 Merge branch 'main' into dev 2026-05-08 13:20:33 +05:30
Gagancreates
0250ca638e chore: reduce viewer cache limit from 5 to 3 2026-05-08 03:13:17 +05:30
Gagancreates
d9d936b7e8 fix: stop reordering cached paths to keep iframe state alive 2026-05-08 03:05:38 +05:30
Gagancreates
49a50279da perf: keep recent html and pdf viewers mounted to preserve state 2026-05-08 03:00:40 +05:30
gagan
4b7911c8ea
fix: context-aware folder/note creation in knowledge panel (#538)
* fix: context-aware folder/note creation with folder highlight and inline rename

* fix: clear folder highlight when a note is opened
2026-05-08 02:42:55 +05:30
Gagancreates
a4cd6abb3a feat: render audio files with native player 2026-05-08 02:11:06 +05:30
Gagancreates
b3519433eb feat: render pdf files via chromium pdfium plugin 2026-05-08 02:07:16 +05:30
Gagancreates
b24113b78e feat: render video files with native controls and seeking 2026-05-08 02:03:55 +05:30
Gagancreates
0d9cf71947 feat: serve workspace files via app:// protocol and add image viewer 2026-05-08 01:54:35 +05:30
Gagancreates
ede98f5378 perf: cache html content by mtime and size in lru of 20 2026-05-08 01:54:26 +05:30
Gagancreates
754561d893 feat: add error, empty, and oversize states to html viewer 2026-05-08 00:59:33 +05:30
Gagancreates
9014c79f2c feat: render html files in knowledge view via sandboxed iframe 2026-05-08 00:40:05 +05:30
Ramnique Singh
62a07618e0
Merge pull request #537 from rowboatlabs/dev
Dev
2026-05-07 18:18:23 +05:30
Ramnique Singh
eb6a7ac466
Merge pull request #536 from rowboatlabs/tracks-in-fm
feat: tracks — frontmatter directives, sidebar UI, multi-trigger
2026-05-07 18:17:56 +05:30
Ramnique Singh
db6757514c feat: tracks — frontmatter directives, sidebar UI, multi-trigger
Recasts the old "track blocks" as "tracks" — directives stored in a
note's frontmatter rather than inline YAML fences and HTML-comment
target regions. The motivation is UX: the inline anatomy made notes
feel like config, leaked into the editing surface, and competed with
the writing flow. Frontmatter is invisible to the body editor, so
moving directives there reclaims the body as just markdown the user
wrote.

The runtime agent now edits the note body freely via standard
workspace tools rather than rewriting a constrained target region.
Each track's instruction names an H2 section to own; the agent
finds or creates that section, updates only its content, and
self-heals position on subsequent runs.

Triggers are now a unified array per track. cron / window / once /
event in any combination, including multi-trigger setups (the
flagship example: a priorities track that rebuilds at three
day-windows and reacts to incoming gmail / calendar events).
window is forgiving — fires once per day anywhere inside its
band — so users opening the app late in the morning still get the
morning run.

The chip-in-editor is gone. Tracks are managed from a right-side
sidebar opened by a Radio-icon button at the top-right of the
editor toolbar. Cmd+K is no longer a Copilot entry point — search-
only — pending a more intuitive invocation surface later.

Today.md ships as the flagship demo of what tracks can do, with a
versioned migration system so future template updates roll out
cleanly to existing users (existing body preserved, old version
backed up).

Copilot is tuned to listen for any signal that the user wants
something dynamic — not just the literal word "track". Strong
phrasings get acted on directly; one-off questions about decaying
information are answered first and then offered as a track. New or
edited tracks run once by default so the user immediately sees
content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:17:26 +05:30
Ramnique Singh
4709e6eb89
Merge pull request #533 from rowboatlabs/coding2
Coding2
2026-05-07 16:37:43 +05:30
arkml
a48887da61
can set a work directory in assistant chats (#534) 2026-05-06 23:14:00 +05:30
Arjun
d515c423ee show the terminal view 2026-05-06 22:24:33 +05:30
Arjun
a18f5dc3dd coding with acpx 2026-05-06 22:24:33 +05:30
Arjun
d6651c4bf8 fix build issues 2026-05-06 22:22:27 +05:30
gagan
0e3d058c29
feat: Gmail-style email block with inbox container layout (#531)
* feat: restyle email block with Gmail-style layout and avatar

* style: apply Google Sans/Roboto font to email block

* feat: add Gmail inbox-style multi-email block with accordion rows

* style: fix sender name casing, weight, and email display in expanded view

* feat: emails inbox block with container layout, two-line rows, Gmail title style
2026-05-06 21:41:26 +05:30
gagan
3630032d21
feat/today-minimal-polish (#532)
* feat: remove emoji headings and polish track block chip styling

- Strip emojis from Today.md section headings (new + existing files via migration)
- Track chip: full-width card style matching email blocks, colored icons per track type
- Larger, taller chip with muted gray background for light/dark mode

* feat: increase track chip icon and text size

* feat: make track block icons configurable via yaml

* fix: migrate missing icon fields in existing Today.md on startup
2026-05-06 19:41:28 +05:30
Arjun
37c1627d79 fix browser cleanup 2026-05-06 17:50:56 +05:30
gagan
0bb58e55ac
feat: minimal Today.md UI polish - no emoji headings, better track chip (#528)
* feat: remove emoji headings and polish track block chip styling

- Strip emojis from Today.md section headings (new + existing files via migration)
- Track chip: full-width card style matching email blocks, colored icons per track type
- Larger, taller chip with muted gray background for light/dark mode

* feat: increase track chip icon and text size

* feat: make track block icons configurable via yaml
2026-05-06 14:34:53 +05:30
Arjun
f26d57e8eb fix sync resume modal copy 2026-05-06 13:10:20 +05:30
Arjun
5e47bd4309 fix shell path issue on mac 2026-05-06 13:02:01 +05:30
arkml
72ed4bd6d9
pull browser-harness skills (#519)
use browser-harness skill without eval or http-fetch
2026-05-06 12:25:10 +05:30
arkml
e54b5cd27f
Background agents (#530)
a common place to track and add background agents
2026-05-06 11:59:37 +05:30
Arjun
7b119fbfcd refine note-writing instructions for self-reference and relationship phrasing 2026-05-05 19:56:57 +05:30
Arjun
c6083de054 show errors in activity tab for knowledge graph 2026-05-05 19:21:32 +05:30
Arjun
c382e3ee8a use gemini as default kg model 2026-05-05 16:08:57 +05:30
Ramnique Singh
d4850dace7 feat: native google sign-in for signed-in users
Signed-in users can now connect Gmail and Calendar directly through
Rowboat instead of going through Composio. Cleaner connection, no
third-party in the data path.

How it works:
- Click "Connect Google" anywhere it appears (sidebar, onboarding,
  settings) and the system browser opens to a Rowboat-hosted page.
  Authorize Google there and the app picks up the connection
  automatically — no client id or secret to paste.
- Token refresh happens through Rowboat's backend, so Google
  credentials never need to live on the user's machine.
- Disconnect cleanly revokes access on Google's side too.

Migration for existing Composio users:
- A one-time modal explains that we've moved off Composio and asks the
  user to reconnect Google directly.
- Their old Composio Gmail / Calendar connections are disconnected
  automatically when the modal first appears.
- All previously-synced emails and calendar events are preserved on
  disk — the new connection picks up where Composio left off rather
  than re-downloading the last week from scratch.
- "I'll do this later" dismisses the modal permanently; the user can
  still reconnect anytime via the connectors UI. (Sync stops in the
  meantime; nothing is deleted.)

Other coverage:
- BYOK mode (users who paste their own Google client id + secret) is
  unchanged — same modal, same local OAuth flow, same behavior.
- Composio integrations for non-Google services (Slack, Linear, etc.)
  are unaffected. Only the Gmail and Calendar paths moved.
- The "Connect Google" button label and connection state now apply
  uniformly to Gmail + Calendar (one OAuth grant covers both).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:29:13 +05:30
Arjun
a76f8bae14 fix sticky browser issue 2026-05-05 11:41:08 +05:30
Arjun
0bd234ddf6 fix browser reload issue 2026-05-04 17:28:04 +05:30
Arjun
93feee15a0 fixed collapsed sidebar issue on chat 2026-05-04 17:20:19 +05:30
arkml
1c2b2ac1fc
feat: native desktop notifications + rowboat:// deep links
Adds INotificationService with an Electron implementation, plus a deep-link
dispatcher (rowboat://) for routing notification clicks back into the app.

Notifications:
- New `notify-user` skill + builtin tool. Title, message, optional primary
    link, optional secondary actions. Supports https:// (opens in browser) and
    rowboat:// (opens in app) targets.
- ElectronNotificationService holds strong refs to active Notification
    instances so click handlers survive GC (otherwise macOS click silently
    no-ops).
- Calendar meeting notifier fires 1-min warnings with "take notes" /
    "join + take notes" actions backed by deep links.

Deep links (rowboat://):
- forge.config.cjs declares the protocol; main.ts wires single-instance
    lock, setAsDefaultProtocolClient, open-url (mac), second-instance (win/
    linux), and first-launch argv extraction.
- New deeplink.ts dispatcher with dispatchUrl(url): main-handled actions
    (rowboat://action?type=...) vs renderer navigation (rowboat://open?...)
    via app:openUrl IPC. Includes pending-URL buffering for first-launch
    delivery before the renderer is ready.
- Renderer parseDeepLink supports file / chat / graph / task /
    suggested-topics targets.
- New app:consumePendingDeepLink IPC for renderer one-time drain on mount.

Refactor: extractConferenceLink moved out of calendar-block.tsx into
shared lib/calendar-event.ts (used by both the block and the take-notes
deep-link handler)
2026-05-04 15:47:30 +05:30
Ramnique Singh
9ed54e2b94
Merge pull request #525 from rowboatlabs/feat/tool-call-grouping
feat: group consecutive tool calls into collapsible summary
2026-04-29 11:07:50 +05:30
Ramnique Singh
17afc935bf
Merge pull request #524 from rowboatlabs/dev
identify signed-in users on every app startup
2026-04-28 20:22:26 +05:30
Ramnique Singh
de176ec458 identify signed-in users on every app startup
Previously identify() only fired during the OAuth completion flow, so
existing installs (signed in before analytics shipped) and every cold
start of v0.3.4+ would emit main-process events under the anonymous
installation_id until the user happened to re-sign-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:21:37 +05:30
Gagancreates
4ca03daa4c feat: group consecutive tool calls into collapsible summary
Consecutive plain tool calls are now grouped into a single collapsible
row instead of rendering as individual items.

- Header shows the currently-executing tool name live with a vertical
  ticker animation, then switches to "Ran N tools" on completion
- Expanding the group reveals each tool call individually collapsible
- Tool calls with pending permission requests render individually
- Special cards (web search, composio connect, app actions) excluded
2026-04-28 20:10:13 +05:30
Ramnique Singh
0dff57e8f7
Merge pull request #523 from rowboatlabs/dev
add posthog analytics for llm usage and auth events
2026-04-28 20:10:13 +05:30
Ramnique Singh
43c1ba719f add posthog analytics for llm usage and auth events
Captures per-LLM-call token usage tagged by feature (copilot chat,
track block, meeting note, knowledge sync), plus sign-in / sign-out
and identity. Renderer and main share one PostHog identity so events
from either process resolve to the same user.

See apps/x/ANALYTICS.md for the event catalog, person properties,
use-case taxonomy, and how to add new events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:40 +05:30
arkml
f14f3b0347
Merge pull request #520 from rowboatlabs/dev
Dev
2026-04-24 18:44:24 +05:30
Ramnique Singh
d42fb26bcc allow per-track model + provider overrides
Track block YAML gains optional `model` and `provider` fields. When set,
the track runner passes them through to `createRun` so this specific
track runs on the chosen model/provider; when unset the global default
flows through (`getTrackBlockModel()` + the resolved provider).

The track skill picks up the new fields automatically via the embedded
`z.toJSONSchema(TrackBlockSchema)` and adds an explicit "Do Not Set"
section: copilot leaves them omitted unless the user named a specific
model or provider for the track. Common bad reasons ("might be faster",
"in case it matters", complex instruction) are called out so the
defaults stay the path of least resistance.

Track modal Details tab shows the values when set, in the same
conditional `<dt>/<dd>` style as the lastRun fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:58:18 +05:30
Ramnique Singh
caf00fae0c configurable kg / meeting / track-block model overrides
Bring back per-category model selection that 5c4aa772 dropped, plus add a
new track-block category. Each is a BYOK-only override on `LlmModelConfig`
(`knowledgeGraphModel`, `meetingNotesModel`, `trackBlockModel`); signed-in
users always get the curated gateway default and never hit the on-disk
config.

Three helpers in core/models/defaults.ts — `getKgModel`,
`getTrackBlockModel`, `getMeetingNotesModel` — each check `isSignedIn`
first (fast path) and fall through to `cfg.<field> ?? cfg.model` for BYOK.

The model is now picked at the invocation site rather than via runtime
agent-name branching: each top-level `createRun` for a polling KG agent
or a track-block update passes `model: await getXxxModel()`. The `model:`
declarations on the affected agent YAMLs are dropped — they were dead
code under the per-call override. Standalone (non-run) callers
`track/routing` and `summarize_meeting` use the helpers inline.

Settings dialog and the two onboarding flows surface the two new fields
("Meeting Notes Model", "Track Block Model") next to the existing
"Knowledge Graph Model"; `repo.setConfig` persists all three per-provider.

Note: the signed-in `RowboatModelSettings` panel still has its
now-defunct kg selector; that's a UI cleanup for a later pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:44:02 +05:30
Ramnique Singh
bdf270b7a1 convert Today.md track blocks to event-driven and batch Gmail sync events
Removes polling schedules from the up-next and calendar track blocks on
Today.md so they refresh only on calendar.synced events, and rewrites
the emails track instruction to consume a multi-thread digest payload.
Batches Gmail sync so one email.synced event covers a whole sync run
(capped at 10 threads per digest) instead of one event per thread,
which collapses Pass 1 routing calls for multi-thread syncs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:15:56 +05:30
Arjun
0bb256879c preserve formatting in chat input text 2026-04-23 21:29:51 +05:30
Arjun
75842fa06b assistant chat ui shows the model name properly 2026-04-23 00:49:06 +05:30
Arjun
f4dbb58a77 add rowboat meeting notes to graph 2026-04-23 00:35:08 +05:30
Ramnique Singh
5c4aa77255 freeze model + provider per run at creation time
The model dropdown was broken in two ways: it wrote to ~/.rowboat/config/models.json
(the BYOK creds file, stamped with a fake `flavor: 'openrouter'` to satisfy zod
when signed in), and the runtime ignored that write entirely for signed-in users
because `streamAgent` hard-coded `gpt-5.4`. Model selection was also globally
scoped, so every chat shared one brain.

This change moves model + provider out of the global config and onto the run
itself, resolved once at runs:create and frozen for the run's lifetime.

## Resolution

`runsCore.createRun` resolves per-field, falling through:

  run.model    = opts.model    ?? agent.model    ?? defaults.model
  run.provider = opts.provider ?? agent.provider ?? defaults.provider

A new `core/models/defaults.ts` is the only place in the codebase that branches
on signed-in state. `getDefaultModelAndProvider()` returns name strings;
`resolveProviderConfig(name)` does the name → full LlmProvider lookup at
runtime. `createProvider` learns about `flavor: 'rowboat'` so the gateway is
just another flavor.

`provider` is stored as a name (e.g. `"rowboat"`, `"openai"`), not a full
LlmProvider object. API keys never get written into the JSONL log; rotating a
key in models.json applies to existing runs without re-creation. Cost: deleting
a provider from settings breaks runs that referenced it (clear error surfaced
via `resolveProviderConfig`).

## Runtime

`streamAgent` no longer resolves anything — it reads `state.runModel` /
`state.runProvider`, looks up the provider config, instantiates. Subflows
inherit the parent run's pair, so KG / inline-task subagents run on whatever
the main run resolved to at creation. The `knowledgeGraphAgents` array,
`isKgAgent`, and the per-agent default constants are gone.

KG / inline-task / pre-built agents declare their preferred model in YAML
frontmatter (claude-haiku-4.5 / claude-sonnet-4.6) — used at resolution time
when those agents are themselves the top-level agent of a run (background
triggers, scheduled tasks, etc.).

## Standalone callers

Non-run LLM call sites (summarize_meeting, track/routing, builtin-tools
parseFile) and `agent-schedule/runner` were branching on signed-in
independently. They all route through `getDefaultModelAndProvider` +
`resolveProviderConfig` + `createProvider` now; `agent-schedule/runner`
switched from raw `runsRepo.create` to `runsCore.createRun` so resolution
applies to scheduled-agent runs too.

## UI

`chat-input-with-mentions` stops calling `models:saveConfig`. The dropdown
notifies the parent via `onSelectedModelChange` ({provider, model} as names);
App.tsx stashes selection per-tab and passes it to the next `runs:create`.
When a run already exists, the input fetches it and renders a static label —
model can't change mid-run.

## Legacy runs

A lenient zod schema in `repo.ts` (`StartEvent.extend(...optional)` plus
`RunEvent.or(LegacyStartEvent)`) parses pre-existing runs. `repo.fetch` fills
missing model/provider from current defaults and returns the strict canonical
`Run` type. No file-rewriting migration; no impact on the canonical schema in
`@x/shared`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:26:01 +05:30
Ramnique Singh
51f2ad6e8a
Merge pull request #517 from rowboatlabs/dev
Dev
2026-04-21 14:39:46 +05:30
Ramnique Singh
15567cd1dd let tool failures be observed by the model instead of killing the run
streamAgent executed tools with no try/catch around the call. A throw
from execTool or from a subflow agent streamed up through streamAgent,
out of trigger's inner catch (which rethrows non-abort errors), and
into the new top-level catch that the previous commit added. That
surfaces the failure — but it ends the run. One misbehaving tool took
down the whole conversation.

Wrap the tool-execution block in a try/catch. On abort, rethrow so the
existing AbortError path still fires. On any other error, convert the
exception into a tool-result payload ({ success: false, error, toolName })
and keep going. The model then sees a tool-result message saying the
tool failed with a specific message and can apologize, retry with
different arguments, pick a different tool, or explain to the user —
the normal recovery moves it already knows how to make.

No change to happy-path tool execution, no change to abort handling,
no change to subflow agent semantics (subflows that themselves error
are treated identically to regular tool errors at the call site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:38:19 +05:30
Ramnique Singh
c81d3cb27b surface silent runtime failures as error events
AgentRuntime.trigger() wrapped its body in try/finally with no outer
catch. An inner catch around the streamAgent for-await only handled
AbortError and rethrew everything else. Call sites fire-and-forget
trigger (runs.ts:26,60,72), so any thrown error became an unhandled
promise rejection. The finally still ran and published
run-processing-end, but nothing told the renderer why — the chat
showed the spinner, then an empty assistant bubble.

Provider misconfig, invalid API keys, unknown model ids, streamText
setup throws, runsRepo.fetch or loadAgent failing, and provider
auth/rate-limit rejections on the first chunk all hit this path on a
first message. All invisible.

Add a top-level catch that formats the error to a string and emits a
{type: "error"} RunEvent via the existing runsRepo/bus path. The
renderer already renders those as a chat bubble plus toast
(App.tsx:2069) — no UI work needed.

No changes to the abort path: user-initiated stops still flow through
the existing inner catch and the signal.aborted branch that emits
run-stopped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:36:00 +05:30
Ramnique Singh
32b6b2f1c0 Merge branch 'main' into dev 2026-04-21 13:36:58 +05:30
tusharmagar
0f051ea467 fix: duplicate navigation button 2026-04-21 13:02:44 +05:30
181 changed files with 19709 additions and 6330 deletions

View file

@ -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
View file

@ -1 +1,2 @@
node_modules/
test-fixtures/

158
apps/x/ANALYTICS.md Normal file
View 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
View 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:0012:00 + a 12:0015: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` |

View file

@ -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:2237` (`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:5166` (`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:650` (`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:2362`.
- **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 4556). 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.

View file

@ -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'),
},
});

View file

@ -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.)',
},

View file

@ -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': {

View file

@ -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 {

View file

@ -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.

View 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();
}

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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();
}
}

View file

@ -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 };

View 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.

View file

@ -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

View file

@ -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>
)
}

View 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&apos;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>
)
}

View file

@ -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" />

File diff suppressed because it is too large Load diff

View file

@ -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()

View file

@ -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>
)

View file

@ -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}

View 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>
)
}

View file

@ -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&apos;ll do this later
</Button>
<Button
size="sm"
onClick={() => {
onReconnect()
onOpenChange(false)
}}
>
Reconnect Google
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -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

View file

@ -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>
)
}

File diff suppressed because it is too large Load diff

View file

@ -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) => {

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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

View 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>
)
}

View file

@ -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 && (

View file

@ -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 && (

View file

@ -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)

View 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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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

View file

@ -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 */}

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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'

View 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>
))}
</>
)
}

View file

@ -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\//, '')
}

View 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>
)
}

View 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&apos;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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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
},
},
}
},
})

View file

@ -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')

View file

@ -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 })]
},
})

View 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);
}

View file

@ -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);
}

View 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,
}
}

View file

@ -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

View file

@ -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)

View file

@ -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,

View 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
}

View file

@ -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()

View 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'
}

View file

@ -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. */

View 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`
}

View 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
}

View 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
}

View file

@ -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()

View file

@ -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;
}

View file

@ -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); }

View file

@ -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",

View file

@ -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

View 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}` : ''}`;
}

View file

@ -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,

View file

@ -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);
});
}

View 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);
}
}

View 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;
}

View 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);
}
}

View 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);
}

View 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();
}

View file

@ -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)

View file

@ -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 {

View file

@ -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 exactlydon'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 writingmalformed 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;

View file

@ -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;

View file

@ -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.

View file

@ -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;

View file

@ -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

View file

@ -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) => ({

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,3 @@
export { ensureLoaded, readSkillContent, refreshFromRemote } from './loader.js';
export type { SkillEntry, SkillsIndex, LoaderStatus } from './loader.js';
export { matchSkillsForUrl } from './matcher.js';

View 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.' };
}
}

View 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