Compare commits

..

No commits in common. "main" and "v0.2.13" have entirely different histories.

247 changed files with 8036 additions and 34245 deletions

View file

@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.15.0
node-version: 24
cache: 'pnpm'
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
@ -111,7 +111,6 @@ jobs:
with:
name: distributables
path: apps/x/apps/main/out/make/*
if-no-files-found: error
retention-days: 30
build-linux:
@ -129,7 +128,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.15.0
node-version: 24
cache: 'pnpm'
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
@ -176,7 +175,6 @@ jobs:
with:
name: distributables-linux
path: apps/x/apps/main/out/make/*
if-no-files-found: error
retention-days: 30
build-windows:
@ -194,7 +192,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.15.0
node-version: 24
cache: 'pnpm'
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
@ -243,5 +241,4 @@ jobs:
with:
name: distributables-windows
path: apps/x/apps/main/out/make/*
if-no-files-found: error
retention-days: 30

View file

@ -108,8 +108,7 @@ Long-form docs for specific features. Read the relevant file before making chang
| Feature | Doc |
|---------|-----|
| 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` |
| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
## Common Tasks

1
apps/x/.gitignore vendored
View file

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

View file

@ -1,161 +0,0 @@
# 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
All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook.
### `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 |
| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event |
| `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` |

View file

@ -1,408 +0,0 @@
# 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 `file-editText`, 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 `file-readText` 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 `file-editText` — 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 ────┘ ▼
file-readText / -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 `file-editText` 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 `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
Three branches by `trigger`:
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills.
- **`timed`** — same as `manual`. Called by the scheduler with no `context`.
- **`event`** — adds a Pass 2 decision block listing the note's `eventMatchCriteria` and the event payload, with the directive to skip the edit if the event isn't truly relevant.
### 5. Live Note skill (Copilot-facing)
- **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills.
- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant.
- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` is interpolated into the "Canonical Schema" section. Edits to `LiveNoteSchema` propagate automatically.
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('live-note')` fires.
- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`.
### 6. Copilot trigger paragraph
- **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded.
- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live Notes" paragraph).
- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…").
- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up.
- **Anti-signals (do NOT make live)**: definitional questions, one-off lookups, manual document editing.
- **Extend-not-fork rule**: explicit guidance — "if the note is already live, extend its existing objective in natural language; never create a second objective."
### 7. `run-live-note-agent` tool — `context` parameter description
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run.
- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-live-note-agent` tool definition).
- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), optional `context`.
- **Output**: flows into `runLiveNoteAgent(..., 'manual')``buildMessage` → appended as `**Context:**` in the agent message.
- **Key use case**: backfill a newly-made-live note so its body isn't empty on day 1.
### 8. Calendar sync digest (event payload template)
- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`.
- **File**: `packages/core/src/knowledge/sync_calendar.ts` (`summarizeCalendarSync`, wrapped by `publishCalendarSyncEvent()`).
- **Output**: markdown with a counts header, `## Changed events` (per-event block: title, ID, time, organizer, location, attendees, truncated description), `## Deleted event IDs`. Capped at ~50 events; descriptions truncated to 500 chars.
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is.
---
## Logging
All live-note logs use the `PrefixLogger` with the prefix `LiveNote:<Component>` so they're greppable as a group. Every component logs lifecycle events at one consistent level.
| Component | Prefix | What it logs |
|---|---|---|
| Scheduler | `LiveNote:Scheduler` | One tick summary per tick when work happened (`tick — scanned N md, K live, fired J, backoff M`). Per-note `<path> — firing (matched cron)` and `<path> — skip (matched window, backoff 4m remaining)`. Quiet when no live notes or none due. |
| Agent (runner) | `LiveNote:Agent` | `<path> — start trigger=cron runId=…`, `<path> — done action=replace summary="…"` (truncated to 120 chars), `<path> — failed: <msg>`, `<path> — skip: already running`. |
| Routing | `LiveNote:Routing` | `event:<id> — routing against N live notes`, `event:<id> — Pass1 → K candidates: a.md, b.md`, `event:<id> — Pass1 batch X failed: …`. |
| Events | `LiveNote:Events` | `event:<id> — received source=gmail type=email.synced`, `event:<id> — dispatching to K candidates: …`, `event:<id> — processed ok=2 errors=0`. |
| Fileops | (only logs failures) | Lock contention or write errors. Otherwise silent. |
Conventions:
- Lower-case verbs (`firing`, `skip`, `done`, `failed`) so lines scan visually.
- File path is always the second token where applicable.
- Run summaries truncated to 120 chars with a single `…` so log lines stay under terminal-width.
- Scheduler emits *one* tick summary per tick, not a row per note. Per-note rows only when something fires or hits a notable skip.
## File Map
| Purpose | File |
|---|---|
| Zod schemas (live note, triggers, events, Pass1) | `packages/shared/src/live-note.ts` |
| IPC channel schemas | `packages/shared/src/ipc.ts` |
| IPC handlers (main process) | `apps/main/src/ipc.ts` |
| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` |
| File operations (`fetchLiveNote`, `setLiveNote`, `patchLiveNote`, `deleteLiveNote`, `setLiveNoteActive`, `readNoteBody`, `listLiveNotes`) | `packages/core/src/knowledge/live-note/fileops.ts` |
| Scheduler (cron / windows) | `packages/core/src/knowledge/live-note/scheduler.ts` |
| Trigger due-check helper (`computeNextDue` / `dueTimedTrigger`) | `packages/core/src/knowledge/live-note/schedule-utils.ts` |
| Event producer + consumer loop | `packages/core/src/knowledge/live-note/events.ts` |
| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/live-note/routing.ts` |
| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` |
| Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` |
| Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` |
| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` |
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` |
| Editor toolbar (Radio button → panel) | `apps/renderer/src/components/editor-toolbar.tsx` |
| Live Note panel (single-view editor) | `apps/renderer/src/components/live-note-sidebar.tsx` |
| Status hook (`useLiveNoteAgentStatus`) | `apps/renderer/src/hooks/use-live-note-agent-status.ts` |
| Renderer frontmatter helper (preserves `live:`) | `apps/renderer/src/lib/frontmatter.ts` |
| App-level listeners (panel open + Copilot edit) | `apps/renderer/src/App.tsx` |
| Live Notes view (sidebar nav target) | `apps/renderer/src/components/live-notes-view.tsx` |
| CSS (panel styles, legacy filenames) | `apps/renderer/src/styles/live-note-panel.css`, `apps/renderer/src/styles/editor.css` |
| Main process startup (schedulers & processors) | `apps/main/src/main.ts` |

343
apps/x/TRACKS.md Normal file
View file

@ -0,0 +1,343 @@
# Track Blocks
> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand.
A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary.
**Example** (a Chicago-time track refreshed hourly):
~~~markdown
```track
trackId: chicago-time
instruction: Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
expression: "0 * * * *"
```
<!--track-target:chicago-time-->
2:30 PM, Central Time
<!--/track-target:chicago-time-->
~~~
## Table of Contents
1. [Product Overview](#product-overview)
2. [Architecture at a Glance](#architecture-at-a-glance)
3. [Technical Flows](#technical-flows)
4. [Schema Reference](#schema-reference)
5. [Prompts Catalog](#prompts-catalog)
6. [File Map](#file-map)
7. [Known Follow-ups](#known-follow-ups)
---
## Product Overview
### Trigger types
A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track.
| Trigger | When it fires | How to express it |
|---|---|---|
| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset |
| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` |
| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` |
| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` |
| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` |
Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals.
### Creating a track
Three paths, all produce identical on-disk YAML:
1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension.
2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`.
3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name.
### Viewing and managing a track
The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running.
Clicking the chip opens the **track modal**, where everything happens:
- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`).
- **Tabs***What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata).
- **Advanced** — expandable raw-YAML editor for power users.
- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region.
- **Footer***Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately).
Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`.
### What Copilot can do
- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`).
- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event.
- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`.
- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill.
### After a run
- The **target region** (between `<!--track-target:ID-->` markers) is rewritten by the track-run agent using the `update-track-content` tool.
- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML.
- The chip pulses while running, then displays the latest `lastRunAt`.
- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook.
---
## Architecture at a Glance
```
Editor chip (display-only) ──click──► TrackModal (React)
├──► IPC: track:get / update /
│ replaceYaml / delete / run
Backend (main process)
├─ Scheduler loop (15 s) ──┐
├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent
└─ Copilot tool run-track-block ──┘ │
update-track-content tool
target region rewritten on disk
```
**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields.
**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context.
---
## Technical Flows
### 4.1 Scheduling (cron / window / once)
- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`.
- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed.
- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
### 4.2 Event pipeline
**Producers** — any data source that should feed tracks emits events:
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`.
**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`.
3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below).
4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/<id>.json`, unlink from `pending/`.
**Pass 1 routing** (`routing.ts:73+ findCandidates`):
- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
- Filter to `active && instruction && eventMatchCriteria` tracks.
- Batches of `BATCH_SIZE = 20`.
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema``candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file.
- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config.
**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region.
### 4.3 Run flow (`triggerTrackUpdate`)
Module: `packages/core/src/knowledge/track/runner.ts`.
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`.
3. **Create agent run**`createRun({ agentId: 'track-run' })`.
4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set.
5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive.
7. **Wait for completion**`waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`.
9. **Store `lastRunSummary`** via `updateTrackBlock`.
10. **Emit `track_run_complete`** with `summary` or `error`.
11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`.
### 4.4 IPC surface
| Channel | Caller → handler | Purpose |
|---|---|---|
| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` |
| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML |
| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML |
| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region |
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook |
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`.
### 4.5 Renderer integration
- **Chip**`apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save.
- **Modal**`apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called.
- **Status hook**`apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state.
- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file.
### 4.6 Copilot skill integration
- **Skill content**`packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called.
- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync.
- **Skill registration**`packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array).
- **Loading trigger**`packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests.
- **Builtin tools**`packages/core/src/application/lib/builtin-tools.ts`:
- `update-track-content` — low-level: rewrite the target region between `<!--track-target:ID-->` markers. Used mainly by the track-run agent.
- `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`).
### 4.7 Concurrency & FIFO guarantees
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC.
- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file.
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too.
- **No retry storms**`lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point.
---
## Schema Reference
All canonical schemas live in `packages/shared/src/track-block.ts`:
- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`.
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`.
- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`.
Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth.
---
## Prompts Catalog
Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`).
### 1. Routing system prompt (Pass 1 classifier)
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them.
- **File**: `packages/core/src/knowledge/track/routing.ts: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

@ -10,13 +10,11 @@
*/
import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
// The banner defines __import_meta_url at the top of the bundle,
// and we use define to replace all import.meta.url references with it.
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
await esbuild.build({
entryPoints: ['./dist/main.js'],
@ -33,12 +31,6 @@ 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'),
'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
},
});

View file

@ -11,9 +11,6 @@ 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.)',
},
@ -56,7 +53,6 @@ module.exports = {
description: 'AI coworker with memory',
name: `Rowboat-win32-${arch}`,
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
setupIcon: path.join(__dirname, 'icons/icon.ico'),
})
},
{
@ -67,9 +63,7 @@ module.exports = {
bin: "rowboat",
description: 'AI coworker with memory',
maintainer: 'rowboatlabs',
homepage: 'https://rowboatlabs.com',
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
homepage: 'https://rowboatlabs.com'
}
})
},
@ -80,9 +74,7 @@ module.exports = {
name: `Rowboat-linux`,
bin: "rowboat",
description: 'AI coworker with memory',
homepage: 'https://rowboatlabs.com',
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
homepage: 'https://rowboatlabs.com'
}
}
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -13,8 +13,6 @@
"make": "electron-forge make"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
"@agentclientprotocol/codex-acp": "^0.0.44",
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
"chokidar": "^4.0.3",

View file

@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
import { URL } from 'url';
const OAUTH_CALLBACK_PATH = '/oauth/callback';
export const DEFAULT_PORT = 8080;
export const PORT_RANGE_SIZE = 10;
const DEFAULT_PORT = 8080;
/** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string {
@ -20,8 +19,13 @@ export interface AuthServerResult {
port: number;
}
function tryBindPort(
port: number,
/**
* Create a local HTTP server to handle OAuth callback
* Listens on http://localhost:8080/oauth/callback
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
*/
export function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (callbackUrl: URL) => void | Promise<void>
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
@ -92,10 +96,8 @@ function tryBindPort(
});
server.on('error', (err: NodeJS.ErrnoException) => {
server.close();
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
// Signal caller to try next port
reject(Object.assign(new Error(err.code), { code: err.code }));
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${port} is already in use`));
} else {
reject(err);
}
@ -103,51 +105,3 @@ function tryBindPort(
});
}
/**
* Create a local HTTP server to handle OAuth callback.
*
* Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
* thrown if it cannot be bound. This is the right behaviour for any provider
* whose redirect URI is pre-registered (Google BYOK, Composio, etc.) those
* callers must keep using the exact port they've handed to the provider.
*
* Opt into `{ fallback: true }` only when the caller is prepared to use the
* port returned in `AuthServerResult` (i.e. the redirect URI is built from the
* actual bound port, not hard-coded). With fallback enabled, scans `port`
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
* both EADDRINUSE and EACCES (the latter is common on Windows when
* Hyper-V/WSL2 reserve the port).
*/
export async function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (callbackUrl: URL) => void | Promise<void>,
opts: { fallback?: boolean } = {},
): Promise<AuthServerResult> {
const fallback = opts.fallback === true;
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
for (let p = port; p <= limit; p++) {
try {
return await tryBindPort(p, onCallback);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}`);
continue;
}
if (!fallback) {
const reason = code === 'EACCES' || code === 'EADDRINUSE'
? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
: (err instanceof Error ? err.message : String(err));
throw new Error(reason);
}
throw new Error(
`No available port found in range ${port}${limit}. Free a port in that range and try again.`
);
}
}
// Unreachable — loop always returns or throws — but satisfies TypeScript
throw new Error(`No available port found in range ${port}${limit}.`);
}

View file

@ -1,24 +1,8 @@
import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.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 type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.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,
@ -68,13 +52,11 @@ export class ElectronBrowserControlService implements IBrowserControlService {
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
const suggestedSkills = await getSuggestedSkills(page?.url);
const success = buildSuccessResult(
return buildSuccessResult(
'new-tab',
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
page,
);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'switch-tab': {
@ -117,9 +99,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
const suggestedSkills = await getSuggestedSkills(page?.url);
const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page);
return suggestedSkills ? { ...success, suggestedSkills } : success;
return buildSuccessResult('navigate', `Navigated to ${target}.`, page);
}
case 'back': {
@ -160,9 +140,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
if (!result.ok || !result.page) {
return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.');
}
const suggestedSkills = await getSuggestedSkills(result.page.url);
const success = buildSuccessResult('read-page', 'Read the current page.', result.page);
return suggestedSkills ? { ...success, suggestedSkills } : success;
return buildSuccessResult('read-page', 'Read the current page.', result.page);
}
case 'click': {

View file

@ -109,62 +109,19 @@ 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;
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;
window.on('closed', () => {
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,6 +293,20 @@ 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

@ -1,165 +0,0 @@
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

@ -1,4 +1,4 @@
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron';
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
import { ipc } from '@x/shared';
import path from 'node:path';
import os from 'node:os';
@ -8,7 +8,6 @@ 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';
@ -31,16 +30,10 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
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';
@ -51,28 +44,14 @@ 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 { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } 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 { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
import {
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';
fetchYaml,
updateTrackBlock,
replaceTrackBlockYaml,
deleteTrackBlock,
} from '@x/core/dist/knowledge/track/fileops.js';
import { browserIpcHandlers } from './browser/ipc.js';
/**
@ -363,7 +342,7 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
}
}
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
@ -392,27 +371,14 @@ export async function startServicesWatcher(): Promise<void> {
});
}
let liveNoteAgentWatcher: (() => void) | null = null;
export function startLiveNoteAgentWatcher(): void {
if (liveNoteAgentWatcher) return;
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
let tracksWatcher: (() => void) | null = null;
export function startTracksWatcher(): void {
if (tracksWatcher) return;
tracksWatcher = trackBus.subscribe((event) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('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);
win.webContents.send('tracks:events', event);
}
}
});
@ -449,16 +415,6 @@ 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,
appVersion: app.getVersion(),
};
},
'workspace:getRoot': async () => {
return workspace.getRoot();
},
@ -489,38 +445,6 @@ 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:sendReply': async (_event, args) => {
return sendThreadReply(args);
},
'gmail:getConnectionStatus': async () => {
return getGmailConnectionStatus();
},
'gmail:getAccountEmail': async () => {
return { email: await getAccountEmail() };
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
'gmail:trashThread': async (_event, args) => {
return trashThread(args.threadId);
},
'gmail:markThreadRead': async (_event, args) => {
return markThreadRead(args.threadId);
},
'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);
},
@ -531,17 +455,12 @@ export function setupIpcHandlers() {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
return { success: true };
},
'codeRun:resolvePermission': async (_event, args) => {
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
registry.resolve(args.requestId, args.decision);
return { success: true };
},
'runs:provideHumanInput': async (_event, args) => {
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
return { success: true };
@ -560,35 +479,6 @@ 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();
@ -640,20 +530,6 @@ export function setupIpcHandlers() {
const config = await repo.getConfig();
return { enabled: config.enabled };
},
'codeMode:getConfig': async () => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
const config = await repo.getConfig();
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
},
'codeMode:setConfig': async (_event, args) => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
invalidateCopilotInstructionsCache();
return { success: true };
},
'codeMode:checkAgentStatus': async () => {
return await checkCodeModeAgentStatus();
},
'granola:setConfig': async (_event, args) => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled });
@ -724,8 +600,11 @@ export function setupIpcHandlers() {
'composio:list-toolkits': async () => {
return composioHandler.listToolkits();
},
'migration:check-composio-google': async () => {
return qualifyAndDisconnectComposioGoogle();
'composio:use-composio-for-google': async () => {
return composioHandler.useComposioForGoogle();
},
'composio:use-composio-for-google-calendar': async () => {
return composioHandler.useComposioForGoogleCalendar();
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
@ -766,11 +645,6 @@ 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);
@ -791,19 +665,6 @@ 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);
@ -919,135 +780,48 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
// 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 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:get': async (_event, args) => {
'track:get': async (_event, args) => {
try {
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track not found' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:set': async (_event, args) => {
'track:update': async (_event, args) => {
try {
await setLiveNote(args.filePath, args.live);
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track vanished after update' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:setActive': async (_event, args) => {
'track:replaceYaml': async (_event, args) => {
try {
await setLiveNoteActive(args.filePath, args.active);
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:delete': async (_event, args) => {
'track:delete': async (_event, args) => {
try {
await deleteLiveNote(args.filePath);
await deleteTrackBlock(args.filePath, args.trackId);
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,8 +4,7 @@ import {
setupIpcHandlers,
startRunsWatcher,
startServicesWatcher,
startLiveNoteAgentWatcher,
startBackgroundTaskAgentWatcher,
startTracksWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
@ -24,35 +23,18 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
import { init as 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 { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.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 container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
import { registerBrowserControlService } from "@x/core/dist/di/container.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
import {
DEEP_LINK_SCHEME,
dispatchUrl,
extractDeepLinkFromArgv,
setMainWindowForDeepLinks,
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
const execAsync = promisify(exec);
@ -62,44 +44,6 @@ 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.).
@ -120,9 +64,7 @@ function initializeExecutionEnvironment(): void {
).trim();
const env = JSON.parse(stdout) as Record<string, string>;
// 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 };
process.env = { ...env, ...process.env };
} catch (error) {
console.error('Failed to load shell environment', error);
}
@ -140,29 +82,16 @@ const rendererPath = app.isPackaged
: path.join(__dirname, "../../../renderer/dist"); // Development
console.log("rendererPath", rendererPath);
// 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)
// Register custom protocol for serving built renderer files in production.
// This keeps SPA routes working when users deep link into the packaged app.
function registerAppProtocol() {
protocol.handle("app", (request) => {
const url = new URL(request.url);
// 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 });
}
}
// Renderer SPA — existing logic
// url.pathname starts with "/"
let urlPath = url.pathname;
// If it's "/" or a SPA route (no extension), serve index.html
if (urlPath === "/" || !path.extname(urlPath)) {
urlPath = "/index.html";
}
@ -181,8 +110,8 @@ protocol.registerSchemesAsPrivileged([
supportFetchAPI: true,
corsEnabled: true,
allowServiceWorkers: true,
// Required for byte-range requests so <video> seeking works.
stream: true,
// optional but often helpful:
// stream: true,
},
},
]);
@ -221,25 +150,18 @@ function createWindow() {
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
webPreferences: {
// IMPORTANT: keep Node out of renderer
nodeIntegration: false,
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();
@ -275,10 +197,10 @@ function createWindow() {
}
app.whenReady().then(async () => {
// 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.
// Register custom protocol before creating window (for production builds)
if (app.isPackaged) {
registerAppProtocol();
}
// Initialize auto-updater (only in production)
if (app.isPackaged) {
@ -307,15 +229,7 @@ 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();
@ -335,29 +249,14 @@ app.whenReady().then(async () => {
// start services watcher
startServicesWatcher();
// start live-note agent event watcher (forwards bus → renderer)
startLiveNoteAgentWatcher();
// start tracks watcher
startTracksWatcher();
// start bg-task agent event watcher (forwards bus → renderer)
startBackgroundTaskAgentWatcher();
// start track scheduler (cron/window/once)
initTrackScheduler();
// 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();
// If the stored Google grant predates a scope change (only old scopes),
// disconnect it now so the user re-connects with the current scopes before
// any Google sync runs against the stale grant.
await disconnectGoogleIfScopesStale();
// start track event processor (consumes events/pending/, triggers matching tracks)
initTrackEventProcessor();
// start gmail sync
initGmailSync();
@ -389,17 +288,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();
// start local sites server for iframe dashboards and other mini apps
initLocalSites().catch((error) => {
console.error('[LocalSites] Failed to start:', error);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
@ -418,16 +309,4 @@ app.on("before-quit", () => {
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
try {
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
} catch {
// nothing live to dispose
}
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

@ -1,84 +0,0 @@
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

@ -1,7 +1,6 @@
import { shell } from 'electron';
import type { Server } from 'http';
import { createAuthServer } from './auth-server.js';
import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
@ -13,14 +12,8 @@ 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';
function buildRedirectUri(port: number): string {
return `http://localhost:${port}/oauth/callback`;
}
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
/** Top-level openid-client messages that often wrap a more specific cause. */
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
@ -117,15 +110,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
}
/**
* Get or create OAuth configuration for a provider.
* `redirectUri` is required for DCR providers it is the actual callback URI
* (including port) that was just bound, so the registration and auth URL stay in sync.
* Get or create OAuth configuration for a provider
*/
async function getProviderConfiguration(
provider: string,
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
credentialsOverride?: { clientId: string; clientSecret: string },
): Promise<Configuration> {
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
const config = await getProviderConfig(provider);
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
if (config.client.mode === 'static' && config.client.clientId) {
@ -166,20 +153,17 @@ async function getProviderConfiguration(
);
}
// Register new client with the actual redirect URI (port already bound)
// Register new client
const scopes = config.scopes || [];
const { config: oauthConfig, registration } = await oauthClient.registerClient(
config.discovery.issuer,
[redirectUri],
[REDIRECT_URI],
scopes
);
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
const boundPort = new URL(redirectUri).port
? parseInt(new URL(redirectUri).port, 10)
: DEFAULT_CALLBACK_PORT;
await clientRepo.saveClientRegistration(provider, registration, boundPort);
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
// Save registration for future use
await clientRepo.saveClientRegistration(provider, registration);
console.log(`[OAuth] ${provider}: DCR registration saved`);
return oauthConfig;
}
@ -201,37 +185,6 @@ async function getProviderConfiguration(
}
}
/**
* Determine which port to start the OAuth callback server on for a DCR provider.
*
* If the provider has an existing registration, probes the port it was registered
* on. If that port is still available, returns it so the existing client_id keeps
* working. If it is blocked, clears the stale registration (forcing re-registration
* on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base.
*
* Exported for unit testing.
*/
export async function resolveStartPort(
provider: string,
clientRepo: IClientRegistrationRepo,
): Promise<number> {
const existingReg = await clientRepo.getClientRegistration(provider);
if (!existingReg) return DEFAULT_CALLBACK_PORT;
const registeredPort = await clientRepo.getRegisteredPort(provider);
try {
// Probe — fixed-port (no fallback) so we know whether the exact registered port is free
const probe = await createAuthServer(registeredPort, () => { /* probe */ });
probe.server.close();
console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`);
return registeredPort;
} catch {
console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`);
await clientRepo.clearClientRegistration(provider);
return DEFAULT_CALLBACK_PORT;
}
}
/**
* Initiate OAuth flow for a provider
*/
@ -247,45 +200,34 @@ 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.' };
}
}
// For static-client providers (Google BYOK) the redirect URI is pre-registered
// at the OAuth provider console on a fixed port — we must not scan.
// For DCR providers, resolveStartPort handles the re-registration trap.
const isStaticClient = providerConfig.client.mode === 'static';
const startPort = isStaticClient
? DEFAULT_CALLBACK_PORT
: await resolveStartPort(provider, getClientRegistrationRepo());
// Get or create OAuth configuration
const config = await getProviderConfiguration(provider, credentials);
// --- Callback server ---
// Declare `state` before the closure so the callback can close over its binding.
// The variable is assigned below, before shell.openExternal, so it is always
// set by the time any browser request arrives.
let state = '';
// Generate PKCE codes
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
const state = oauthClient.generateState();
// Get scopes from config
const scopes = providerConfig.scopes || [];
// Store flow state
activeFlows.set(state, { codeVerifier, provider, config });
// Build authorization URL
const authUrl = oauthClient.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: scopes.join(' '),
code_challenge: codeChallenge,
state,
});
// Create callback server
let callbackHandled = false;
const { server, port: boundPort } = await createAuthServer(
startPort,
async (callbackUrl) => {
const { server } = await createAuthServer(8080, async (callbackUrl) => {
// Guard against duplicate callbacks (browser may send multiple requests)
if (callbackHandled) return;
callbackHandled = true;
@ -314,15 +256,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
state
);
// 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.
// Save tokens and credentials
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,
});
@ -337,33 +275,16 @@ 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 {
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,
});
}
await getBillingInfo();
} catch (meError) {
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
}
}
// Emit success event to renderer
emitOAuthEvent({
provider,
success: true,
...(signedInUserId ? { userId: signedInUserId } : {}),
});
emitOAuthEvent({ provider, success: true });
} catch (error) {
console.error('OAuth token exchange failed:', error);
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
@ -386,50 +307,18 @@ export async function connectProvider(provider: string, credentials?: { clientId
activeFlow = null;
}
}
},
// Static providers (Google BYOK) keep fixed-port behaviour to match the
// pre-registered redirect URI at the provider's console. DCR providers
// can fall back since we register the actual bound port below.
{ fallback: !isStaticClient },
);
// Server is bound. Any throw between here and `activeFlow = ...` would
// leak the port — `cancelActiveFlow` only closes it once activeFlow is set.
try {
// TOCTOU guard: resolveStartPort probed the registered port and found it
// free, but the port could have been grabbed between probe and real bind,
// causing fallback to a different port. The cached client_id is registered
// for the old port — clear it so getProviderConfiguration re-registers
// with the actual bound port.
if (!isStaticClient && boundPort !== startPort) {
console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`);
await getClientRegistrationRepo().clearClientRegistration(provider);
}
const redirectUri = buildRedirectUri(boundPort);
const config = await getProviderConfiguration(provider, redirectUri, credentials);
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
state = oauthClient.generateState();
const scopes = providerConfig.scopes || [];
activeFlows.set(state, { codeVerifier, provider, config });
const authUrl = oauthClient.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: scopes.join(' '),
code_challenge: codeChallenge,
state,
});
// Set timeout to clean up abandoned flows (2 minutes)
// This prevents memory leaks if user never completes the OAuth flow
const cleanupTimeout = setTimeout(() => {
if (activeFlow?.state === state) {
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
cancelActiveFlow('timed_out');
}
}, 2 * 60 * 1000);
}, 2 * 60 * 1000); // 2 minutes
// Store complete flow state for cleanup
activeFlow = {
provider,
state,
@ -440,16 +329,8 @@ export async function connectProvider(provider: string, credentials?: { clientId
// Open in system browser (shares cookies/sessions with user's regular browser)
shell.openExternal(authUrl.toString());
// Wait for callback (server will handle it)
return { success: true };
} catch (setupError) {
// Post-bind setup failed — close the server so the port is released and
// a retry isn't blocked by our own zombie listener.
server.close();
if (state) {
activeFlows.delete(state);
}
throw setupError;
}
} catch (error) {
console.error('OAuth connection failed:', error);
return {
@ -459,70 +340,13 @@ 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', signal: AbortSignal.timeout(5000) });
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 };
@ -532,81 +356,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
}
}
/**
* Startup migration for Google scope changes. When a connected Google grant was
* issued before a scope was added (e.g. old installs on gmail.readonly that
* never received gmail.modify), invalidate it so the user is prompted to
* reconnect and re-grant with the current scopes. The currently-requested
* scopes in the provider config are the source of truth: a grant missing any
* of them is treated as stale.
*
* We revoke + clear the stale token but DELIBERATELY keep the provider entry
* with an `error` set rather than calling disconnectProvider (which deletes the
* whole entry). The renderer's reconnect prompts the sidebar "Reconnect your
* accounts" alert and the connectors "Reconnect" row key off this `error`
* field, not off the connected flag. A fully deleted entry has no error and is
* indistinguishable from "never connected", so no prompt would ever appear.
*
* Tokens with no recorded scopes (very old installs that never persisted them)
* are also treated as stale. Safe to call on every startup it's a no-op once
* the grant covers all current scopes, and once invalidated the early return on
* the missing token keeps it from re-running until the user reconnects.
*/
export async function disconnectGoogleIfScopesStale(): Promise<void> {
try {
const oauthRepo = getOAuthRepo();
const connection = await oauthRepo.read('google');
// Not connected (or already invalidated) — nothing to migrate.
if (!connection.tokens) {
return;
}
const providerConfig = await getProviderConfig('google');
const requiredScopes = providerConfig.scopes ?? [];
if (requiredScopes.length === 0) {
return;
}
const granted = new Set(connection.tokens.scopes ?? []);
const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
if (missingScopes.length === 0) {
return;
}
console.log(
`[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
'invalidating it so the user is prompted to reconnect with the new scopes.'
);
// Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
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', signal: AbortSignal.timeout(5000) });
if (!res.ok) {
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
}
} catch (error) {
console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
}
}
// Drop the stale token but keep the entry with an error so the reconnect
// prompt fires (see the note above).
await oauthRepo.upsert('google', {
tokens: null,
error: 'Google permissions changed. Please reconnect to continue.',
});
// Nudge any already-open window to re-read state. The renderer's initial
// mount also re-reads, so the prompt shows even if no window is up yet.
emitOAuthEvent({ provider: 'google', success: false });
} catch (error) {
console.error('[OAuth] Google scope migration check failed:', error);
}
}
/**
* Get access token for a provider (internal use only)
* Refreshes token if expired

View file

@ -1,41 +0,0 @@
# 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

@ -9,7 +9,6 @@
"preview": "vite preview"
},
"dependencies": {
"@eigenpal/docx-editor-react": "^1.0.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@ -26,16 +25,14 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.18",
"@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",
"@tiptap/extension-image": "^3.16.0",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@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",
"@x/preload": "workspace:*",
"@x/shared": "workspace:*",
"ai": "^5.0.117",
@ -47,21 +44,10 @@
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"posthog-js": "^1.332.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-history": "^1.5.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.7",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8",
"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

@ -9,7 +9,6 @@ import { useState, useRef, useEffect } from "react";
export type AskHumanRequestProps = ComponentProps<"div"> & {
query: string;
options?: string[];
onResponse: (response: string) => void;
isProcessing?: boolean;
};
@ -17,21 +16,17 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
export const AskHumanRequest = ({
className,
query,
options,
onResponse,
isProcessing = false,
...props
}: AskHumanRequestProps) => {
const [response, setResponse] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasOptions = Array.isArray(options) && options.length > 0;
useEffect(() => {
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
if (!hasOptions) {
// Auto-focus the textarea when component mounts
textareaRef.current?.focus();
}
}, [hasOptions]);
}, []);
const handleSubmit = () => {
const trimmed = response.trim();
@ -41,11 +36,6 @@ export const AskHumanRequest = ({
}
};
const handleOptionClick = (option: string) => {
if (isProcessing) return;
onResponse(option);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@ -75,22 +65,6 @@ export const AskHumanRequest = ({
{query}
</p>
</div>
{hasOptions ? (
<div className="flex flex-wrap gap-2">
{options!.map((option) => (
<Button
key={option}
variant="outline"
size="sm"
onClick={() => handleOptionClick(option)}
disabled={isProcessing}
className="bg-background"
>
{option}
</Button>
))}
</div>
) : (
<div className="space-y-2">
<Textarea
ref={textareaRef}
@ -115,7 +89,6 @@ export const AskHumanRequest = ({
</Button>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -1,100 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
export type AutoPermissionDecisionProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>;
decision: "allow" | "deny";
reason: string;
permission?: z.infer<typeof ToolPermissionMetadata>;
};
const fileActionLabels: Record<string, string> = {
read: "Read file",
list: "List folder",
search: "Search files",
write: "Write files",
delete: "Delete path",
};
export function AutoPermissionDecision({
className,
toolCall,
decision,
reason,
permission,
...props
}: AutoPermissionDecisionProps) {
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const filePermission = permission?.kind === "file" ? permission : null;
const allowed = decision === "allow";
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
allowed
? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30"
: "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20",
className,
)}
{...props}
>
<div className="space-y-3 p-4">
<div className="flex items-start gap-3">
{allowed ? (
<CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" />
) : (
<ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
{allowed ? "Auto Allowed" : "Auto Denied"}
</h3>
<Badge variant="secondary" className="bg-secondary text-foreground">
<Terminal className="mr-1 size-3" />
{toolCall.toolName}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{reason}</p>
</div>
</div>
{command && (
<div className="rounded-md border bg-background/50 p-3">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre>
</div>
)}
{filePermission && (
<div className="space-y-3 rounded-md border bg-background/50 p-3">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p>
<p className="text-xs font-medium text-foreground">
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
</p>
</div>
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Path{filePermission.paths.length === 1 ? "" : "s"}
</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">
{filePermission.paths.join("\n")}
</pre>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -8,10 +9,9 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & {
@ -22,15 +22,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onDeny?: () => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
permission?: z.infer<typeof ToolPermissionMetadata>;
};
const fileActionLabels: Record<string, string> = {
read: "Read file",
list: "List folder",
search: "Search files",
write: "Write files",
delete: "Delete path",
};
export const PermissionRequest = ({
@ -42,33 +33,26 @@ export const PermissionRequest = ({
onDeny,
isProcessing = false,
response = null,
permission,
...props
}: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
const command = toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const filePermission = permission?.kind === "file" ? permission : null;
const isResponded = response !== null;
const isApproved = response === 'approve';
// Once a response is chosen, collapse the details to just the header.
// Users can click the header to expand them again.
const [expanded, setExpanded] = useState(false);
const showDetails = !isResponded || expanded;
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
isResponded
? isApproved
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
className
)}
@ -76,14 +60,17 @@ export const PermissionRequest = ({
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
{!isResponded && (
{isResponded ? (
isApproved ? (
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
) : (
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
)
) : (
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<div
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
>
<div className="flex items-center gap-2">
<div className="flex-1">
<h3 className="font-semibold text-sm text-foreground">
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
@ -93,15 +80,30 @@ export const PermissionRequest = ({
</p>
</div>
{isResponded && (
<ChevronDownIcon
<Badge
variant="secondary"
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
expanded ? "rotate-180" : "rotate-0"
"shrink-0",
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
)}
/>
>
{isApproved ? (
<>
<CheckIcon className="size-3 mr-1" />
Approved
</>
) : (
<>
<XIcon className="size-3 mr-1" />
Denied
</>
)}
</Badge>
)}
</div>
{showDetails && command && (
{command && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Command
@ -111,35 +113,7 @@ export const PermissionRequest = ({
</pre>
</div>
)}
{showDetails && filePermission && (
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Action
</p>
<p className="text-xs font-medium text-foreground">
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Path{filePermission.paths.length === 1 ? "" : "s"}
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{filePermission.paths.join("\n")}
</pre>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Approval Scope
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{filePermission.pathPrefix}
</pre>
</div>
</div>
)}
{showDetails && !command && !filePermission && toolCall.arguments && (
{!command && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments
@ -159,12 +133,12 @@ export const PermissionRequest = ({
size="sm"
onClick={onApprove}
disabled={isProcessing}
className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
className={cn("flex-1", command && "rounded-r-none")}
>
<CheckIcon className="size-4" />
Approve
</Button>
{(command || filePermission) && (
{command && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View file

@ -1,28 +1,22 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleCheck,
LoaderIcon,
ShieldCheckIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
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 { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
@ -51,68 +45,51 @@ const ToolCode = ({
</pre>
);
export type ToolAutoPermissionDetail = {
decision: "allow";
reason: string;
};
export type ToolProps = ComponentProps<typeof Collapsible>;
export type ToolProps = ComponentProps<typeof Collapsible> & {
autoPermissionDetail?: ToolAutoPermissionDetail;
};
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
const toolCard = (
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn(
autoPermissionDetail
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className
)}
className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
>
{children}
</Collapsible>
);
if (!autoPermissionDetail) return toolCard;
return (
<div className="not-prose mb-4 w-full">
{toolCard}
<div className="mt-1 flex justify-end px-3">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
Auto-approved
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" className="max-w-sm">
{autoPermissionDetail.reason}
</TooltipContent>
</Tooltip>
</div>
</div>
);
};
/>
);
export type ToolHeaderProps = {
title?: string;
type: ToolUIPart["type"];
state: ToolUIPart["state"];
className?: string;
/** Hide the leading status icon (used for child rows inside a tool group). */
hideLeadIcon?: boolean;
};
// Lead icon shown to the left of the tool label: spinner while running, a
// green check when done, a red cross on error. Shared by ToolHeader (single
// tools) and the tool-call group.
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => {
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />;
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels: Record<ToolUIPart["state"], string> = {
"input-streaming": "Pending",
"input-available": "Running",
// @ts-expect-error state only available in AI SDK v6
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"output-available": "Completed",
"output-error": "Error",
"output-denied": "Denied",
};
const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
// @ts-expect-error state only available in AI SDK v6
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
@ -120,7 +97,6 @@ export const ToolHeader = ({
title,
type,
state,
hideLeadIcon,
...props
}: ToolHeaderProps) => {
const displayTitle = title ?? type.split("-").slice(1).join("-")
@ -128,13 +104,13 @@ export const ToolHeader = ({
return (
<CollapsibleTrigger
className={cn(
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{!hideLeadIcon && getLeadIcon(state)}
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
<span
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
title={displayTitle}
@ -142,7 +118,10 @@ export const ToolHeader = ({
{displayTitle}
</span>
</div>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
<div className="flex shrink-0 items-center gap-3">
{getStatusBadge(state)}
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
)
};
@ -152,7 +131,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
@ -245,98 +224,3 @@ 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 toolCount = group.items.length
const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}`
const actions = isCompleted ? getToolActionsSummary(group.items) : ''
// Plain string used as the AnimatePresence key + tooltip; the rendered node
// shows the action summary in a lighter gray than the "Ran N tools" prefix.
const summaryText = isCompleted
? `${ranLabel} · ${actions}`
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
const summaryNode: ReactNode = isCompleted
? <>{ranLabel} <span className="font-normal text-muted-foreground">{`· ${actions}`}</span></>
: summaryText
const leadIcon = getLeadIcon(state)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
{leadIcon}
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={summaryText}
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={summaryText}
>
{summaryNode}
</motion.span>
</AnimatePresence>
</div>
</div>
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
<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 rounded-[20px] border-border/60 bg-transparent hover:border-border/60"
>
<ToolHeader
title={getToolDisplayName(tool)}
type={`tool-${tool.name}`}
state={toolState}
className="text-muted-foreground"
hideLeadIcon
/>
<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

@ -5,14 +5,12 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckCircleIcon,
ChevronDownIcon,
GlobeIcon,
LoaderIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
interface WebSearchResultProps {
query: string;
@ -21,220 +19,40 @@ interface WebSearchResultProps {
title?: string;
}
// How long each fetched website stays on the rolling header before the
// next one slides in. Kept slow enough to read the domain + title.
const ROLL_INTERVAL_MS = 700;
// How many favicons to show in the settled stack before the rest collapse
// into a "+N" chip. The text names this many domains too, so the chip count
// (total - MAX_STACK) lines up with the "and N others" in the summary.
const MAX_STACK = 3;
function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
return new URL(url).hostname;
} catch {
return url;
}
}
function faviconUrl(domain: string, size = 32): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
}
// Collapse the result list into unique domains, preserving order.
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const result of results) {
const domain = getDomain(result.url);
if (seen.has(domain)) continue;
seen.add(domain);
out.push(domain);
}
return out;
}
// Summary with text hierarchy: "Searched" + "and N others" are secondary
// weight/color, the domain names are primary text at medium weight.
function buildSearchedSummary(domains: string[]): React.ReactNode {
const muted = "font-normal text-muted-foreground";
const name = (d: string) => <span className="font-medium text-foreground">{d}</span>;
if (domains.length === 1) {
return (
<>
<span className={muted}>Searched </span>
{name(domains[0])}
</>
);
}
if (domains.length === 2) {
return (
<>
<span className={muted}>Searched </span>
{name(domains[0])}
<span className={muted}> and </span>
{name(domains[1])}
</>
);
}
const others = domains.length - 2;
return (
<>
<span className={muted}>Searched </span>
{name(domains[0])}
<span className={muted}>, </span>
{name(domains[1])}
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
</>
);
}
type RollPhase = "searching" | "rolling" | "settled";
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
const isRunning = status === "pending" || status === "running";
const [open, setOpen] = useState(false);
const domains = useMemo(() => uniqueDomains(results), [results]);
// Drive the one-shot rolling reveal. Results arrive all at once, so we
// simulate "fetching one site at a time" by stepping through them with the
// same slide animation the tool group uses, then settle on a summary.
// `settled` is seeded from the initial status so a card loaded already-
// complete from history skips straight to the summary (no roll).
const [settled, setSettled] = useState(() => !isRunning);
const [rollIndex, setRollIndex] = useState(0);
// Phase is fully derived: searching while the tool runs, rolling once
// results land, then settled. No setState-in-effect needed for transitions.
const phase: RollPhase = isRunning
? "searching"
: !settled && results.length > 0
? "rolling"
: "settled";
// Warm the browser cache for every favicon the moment results arrive, so
// each icon is already loaded by the time its row rolls in (~700ms each).
// Without this the network fetch lags the text and rows flash icon-less.
useEffect(() => {
for (const result of results) {
const img = new Image();
img.src = faviconUrl(getDomain(result.url));
}
}, [results]);
// Advance the roll, then settle after the last site has had its moment.
// setState only fires inside the timeout callback, never synchronously.
useEffect(() => {
if (phase !== "rolling") return;
const isLast = rollIndex >= results.length - 1;
const timer = setTimeout(
() => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)),
ROLL_INTERVAL_MS,
);
return () => clearTimeout(timer);
}, [phase, rollIndex, results.length]);
// Build the content for the compact (collapsed) header line. Each distinct
// value gets a unique key so AnimatePresence runs the slide transition.
let headerKey: string;
let headerContent: React.ReactNode;
if (phase === "searching") {
headerKey = "searching";
headerContent = (
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 shrink-0 animate-spin" />
<span className="truncate">Searching the web&hellip;</span>
</span>
);
} else if (phase === "rolling") {
const result = results[rollIndex];
const domain = getDomain(result.url);
headerKey = `roll-${rollIndex}`;
headerContent = (
<span className="flex min-w-0 flex-1 items-center gap-2">
<img src={faviconUrl(domain)} alt="" className="size-4 shrink-0 rounded-sm bg-muted/60" />
<span className="truncate">
<span className="text-muted-foreground">{domain}</span>
<span className="text-muted-foreground/50"> &middot; </span>
<span>{result.title}</span>
</span>
</span>
);
} else {
headerKey = "settled";
const stack = domains.slice(0, MAX_STACK);
// Chip count matches the "and N others" in the text (total minus the 2
// named domains), shown only when there are sites beyond the stack.
const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0;
headerContent = (
<span className="flex min-w-0 flex-1 items-center gap-2.5">
{domains.length > 0 ? (
<span className="flex shrink-0 items-center">
{stack.map((domain, i) => (
<img
key={domain}
src={faviconUrl(domain)}
alt=""
className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0"
style={{ zIndex: stack.length - i }}
/>
))}
{overflow > 0 && (
<span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground">
+{overflow}
</span>
)}
</span>
) : (
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate text-sm">
{domains.length > 0 ? buildSearchedSummary(domains) : title}
</span>
</span>
);
}
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={headerKey}
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 flex items-center text-left font-medium text-sm"
>
{headerContent}
</motion.span>
</AnimatePresence>
</div>
<div className="flex shrink-0 items-center gap-2">
{phase === "settled" && domains.length > 0 && (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{domains.length} source{domains.length !== 1 ? "s" : ""}
</span>
)}
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
<Collapsible defaultOpen 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 items-center gap-2">
<GlobeIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
<div className="px-4 pb-3 space-y-3">
{/* Query */}
<CollapsibleContent>
<div className="px-3 pb-3 space-y-3">
{/* Query + result count */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<GlobeIcon className="size-3.5 shrink-0" />
<span className="truncate">{query}</span>
</div>
{results.length > 0 && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{results.length} result{results.length !== 1 ? "s" : ""}
</span>
)}
</div>
{/* Results list */}
{results.length > 0 && (
@ -255,7 +73,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
>
<div className="flex items-center gap-2 min-w-0">
<img
src={faviconUrl(domain)}
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="size-4 shrink-0"
/>
@ -270,14 +88,21 @@ export function WebSearchResult({ query, results, status, title = "Searched the
</div>
)}
{/* Status — only while the search is still running. */}
{isRunning && (
{/* Status */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{isRunning ? (
<>
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching...</span>
</div>
</>
) : (
<>
<CheckCircleIcon className="size-3.5 text-green-600" />
<span>Done</span>
</>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);

View file

@ -1,60 +0,0 @@
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, FolderOpen, Pencil, Trash2 } from 'lucide-react'
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
@ -103,18 +103,9 @@ 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')
@ -928,10 +919,6 @@ 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

@ -1,61 +0,0 @@
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import type { BillingErrorMatch } from "@/lib/billing-error"
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
interface BillingErrorDialogProps {
open: boolean
match: BillingErrorMatch | null
onOpenChange: (open: boolean) => void
}
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
if (!open) return
window.ipc
.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [open])
if (!match) return null
const handleUpgrade = () => {
if (appUrl) window.open(`${appUrl}?intent=upgrade`)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{match.title}</DialogTitle>
<DialogDescription>{match.subtitle}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
<Button onClick={handleUpgrade} disabled={!appUrl}>
{match.cta}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -49,7 +49,6 @@ const BLOCKING_OVERLAY_SLOTS = new Set([
interface BrowserPaneProps {
onClose: () => void
forceHidden?: boolean
}
const getActiveTab = (state: BrowserState) =>
@ -86,7 +85,7 @@ const getBrowserTabTitle = (tab: BrowserTabState) => {
}
}
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
export function BrowserPane({ onClose }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
@ -176,12 +175,6 @@ export function BrowserPane({ onClose, forceHidden = false }: 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
@ -198,7 +191,7 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
pushBounds(bounds)
setViewVisible(true)
return bounds
}, [forceHidden, measureBounds, pushBounds, setViewVisible])
}, [measureBounds, pushBounds, setViewVisible])
useEffect(() => {
syncView()

View file

@ -1,106 +0,0 @@
import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatRelativeTime } from '@/lib/relative-time'
export interface ChatEmptyStateRun {
id: string
title?: string
createdAt: string
}
interface ChatEmptyStateProps {
recentRuns?: ChatEmptyStateRun[]
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
/** Fill the composer with a starter prompt (does not submit). */
onPickPrompt: (prompt: string) => void
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
wide?: boolean
}
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
{ icon: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
{ icon: Telescope, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' },
]
/**
* Empty-state body for the chat surface: greeting, recent chats, and starter
* action cards. Shown in both the side-pane copilot and full-screen chat.
*/
export function ChatEmptyState({
recentRuns = [],
onSelectRun,
onOpenChatHistory,
onPickPrompt,
wide = false,
}: ChatEmptyStateProps) {
return (
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
<Sparkles className="size-[17px]" />
</div>
<div>
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
</div>
</div>
{recentRuns.length > 0 && (
<div>
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="flex-1">Recent chats</span>
{onOpenChatHistory && (
<button
type="button"
onClick={onOpenChatHistory}
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
>
View all
<ArrowUpRight className="size-3" />
</button>
)}
</div>
<div className="flex flex-col gap-0.5">
{recentRuns.slice(0, 4).map((run) => (
<button
key={run.id}
type="button"
onClick={() => onSelectRun?.(run.id)}
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
</button>
))}
</div>
</div>
)}
<div>
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
</div>
<div className="flex flex-col gap-2">
{SUGGESTED_ACTIONS.map((action) => (
<button
key={action.title}
type="button"
onClick={() => onPickPrompt(action.prompt)}
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-[12.8px] font-medium">{action.title}</div>
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View file

@ -1,114 +0,0 @@
import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { formatRelativeTime } from '@/lib/relative-time'
export interface ChatHeaderRecentRun {
id: string
title?: string
createdAt: string
}
export interface ChatHeaderProps {
activeTitle: string
onNewChatTab: () => void
recentRuns?: ChatHeaderRecentRun[]
activeRunId?: string | null
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
}
/**
* Header controls for the copilot/chat surface: the active-chat title with a
* recent-chats history dropdown, plus the new-chat button. Rendered identically
* whether the chat lives in the side pane (ChatSidebar) or full screen (App
* content header). There is a single chat conversation at a time switching
* between chats happens through the history dropdown.
*/
export function ChatHeader({
activeTitle,
onNewChatTab,
recentRuns = [],
activeRunId,
onSelectRun,
onOpenChatHistory,
}: ChatHeaderProps) {
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
return (
<>
{hasHistory ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 text-sm font-medium text-foreground outline-none hover:bg-accent/60"
aria-label="Chat history"
>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{activeTitle}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{recentRuns.length > 0 && (
<DropdownMenuLabel className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</DropdownMenuLabel>
)}
{recentRuns.slice(0, 6).map((run) => (
<DropdownMenuItem
key={run.id}
onClick={() => onSelectRun?.(run.id)}
className={cn('gap-2', activeRunId === run.id && 'bg-accent')}
>
<span className="min-w-0 flex-1 truncate">{run.title || '(Untitled chat)'}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatRelativeTime(run.createdAt)}
</span>
</DropdownMenuItem>
))}
{onOpenChatHistory && (
<>
{recentRuns.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onOpenChatHistory} className="gap-2 text-primary">
<ArrowUpRight className="size-4" />
View all chats
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 text-sm font-medium text-foreground">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{activeTitle}</span>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onNewChatTab}
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="New chat"
>
<Plus className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
</>
)
}

View file

@ -1,177 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatRelativeTime } from '@/lib/relative-time'
type Run = {
id: string
title?: string
createdAt: string
agentId: string
}
type ChatHistoryViewProps = {
runs: Run[]
currentRunId?: string | null
processingRunIds?: Set<string>
onSelectRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onDeleteRun: (runId: string) => Promise<void> | void
onNewChat?: () => void
onOpenSearch?: () => void
}
export function ChatHistoryView({
runs,
currentRunId,
processingRunIds,
onSelectRun,
onOpenInNewTab,
onDeleteRun,
onNewChat,
onOpenSearch,
}: ChatHistoryViewProps) {
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
})
}, [runs])
const handleConfirmDelete = useCallback(async () => {
if (!pendingDeleteId) return
const id = pendingDeleteId
setPendingDeleteId(null)
await onDeleteRun(id)
}, [pendingDeleteId, onDeleteRun])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Chat history</h1>
<div className="flex items-center gap-2">
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
)}
{onNewChat && (
<Button size="sm" onClick={onNewChat}>
<SquarePen className="size-4" />
New chat
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0">Created</div>
</div>
{sortedRuns.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No chats yet.</div>
) : (
sortedRuns.map((run) => {
const isActive = currentRunId === run.id
const isProcessing = processingRunIds?.has(run.id)
return (
<ContextMenu key={run.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={(e) => {
if (e.metaKey && onOpenInNewTab) {
onOpenInNewTab(run.id)
} else {
onSelectRun(run.id)
}
}}
className={[
'flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent',
isActive ? 'bg-accent/60' : '',
].join(' ')}
>
<div className="flex flex-1 items-center gap-2 min-w-0">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.createdAt)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isProcessing && (
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
})
)}
</div>
</div>
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => { if (!open) setPendingDeleteId(null) }}>
<DialogContent showCloseButton={false} className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
Are you sure you want to delete this chat?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -10,19 +10,12 @@ import {
FileSpreadsheet,
FileText,
FileVideo,
FolderCheck,
FolderClock,
FolderCog,
FolderOpen,
Globe,
Headphones,
ImagePlus,
LoaderIcon,
Mic,
Plus,
ShieldCheck,
Square,
Terminal,
X,
} from 'lucide-react'
@ -30,12 +23,8 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@ -67,12 +56,6 @@ export type StagedAttachment = {
}
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
const MAX_STORED_RECENT_WORK_DIRS = 8
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
const providerDisplayNames: Record<string, string> = {
@ -86,27 +69,13 @@ const providerDisplayNames: Record<string, string> = {
rowboat: 'Rowboat',
}
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
interface ConfiguredModel {
provider: ProviderName
flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
model: string
}
type RecentWorkDir = {
path: string
lastUsedAt: number
}
export interface SelectedModel {
provider: string
model: string
}
export type PermissionMode = 'manual' | 'auto'
function getSelectedModelDisplayName(model: string) {
return model.split('/').pop() || model
apiKey?: string
baseURL?: string
headers?: Record<string, string>
knowledgeGraphModel?: string
}
function getAttachmentIcon(kind: AttachmentIconKind) {
@ -128,86 +97,8 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
}
}
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
if (typeof value === 'string') {
const path = value.trim()
return path ? { path, lastUsedAt: 0 } : null
}
if (!value || typeof value !== 'object') return null
const entry = value as Record<string, unknown>
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
? entry.lastUsedAt
: 0
return path ? { path, lastUsedAt } : null
}
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
const parsed = JSON.parse(result.data)
if (!Array.isArray(parsed)) return []
const seen = new Set<string>()
const dirs: RecentWorkDir[] = []
for (const value of parsed) {
const entry = normalizeRecentWorkDir(value)
if (!entry || seen.has(entry.path)) continue
seen.add(entry.path)
dirs.push(entry)
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
}
return dirs
} catch {
// File missing or invalid — no recents yet.
return []
}
}
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
try {
await window.ipc.invoke('workspace:writeFile', {
path: RECENT_WORK_DIRS_CONFIG_PATH,
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
})
} catch (err) {
console.error('Failed to persist recent work directories', err)
}
// Notify other mounted chat inputs in this window to re-read.
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
}
function formatRecentWorkDirTime(lastUsedAt: number) {
if (!lastUsedAt) return ''
const now = Date.now()
const diffMs = Math.max(0, now - lastUsedAt)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diffMs < minute) return 'now'
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
const used = new Date(lastUsedAt)
const yesterday = new Date(now - day)
if (
used.getFullYear() === yesterday.getFullYear() &&
used.getMonth() === yesterday.getMonth() &&
used.getDate() === yesterday.getDate()
) {
return 'Yesterday'
}
if (diffMs < 7 * day) {
return used.toLocaleDateString(undefined, { weekday: 'short' })
}
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function compactWorkDirPath(path: string) {
return path.replace(/^\/Users\/[^/]+/, '~')
}
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
@ -229,12 +120,6 @@ 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
/** Work directory for this chat (per-chat). Null when none is set. */
workDir?: string | null
/** Fired when the user sets/changes/clears the work directory for this chat. */
onWorkDirChange?: (value: string | null) => void
}
function ChatInputInner({
@ -260,9 +145,6 @@ function ChatInputInner({
ttsMode,
onToggleTts,
onTtsModeChange,
onSelectedModelChange,
workDir = null,
onWorkDirChange,
}: ChatInputInnerProps) {
const controller = usePromptInputController()
const message = controller.textInput.value
@ -273,42 +155,9 @@ 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 [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
// When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => {
if (!runId) {
setLockedModel(null)
setPermissionMode('auto')
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 })
}
setPermissionMode(run.permissionMode ?? 'manual')
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
return () => { cancelled = true }
}, [runId])
useEffect(() => {
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
syncRecentWorkDirs()
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
return () => {
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
}
}, [])
// Check Rowboat sign-in state
useEffect(() => {
@ -327,20 +176,42 @@ function ChatInputInner({
return cleanup
}, [])
// Load the list of models the user can choose from.
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
// Load model config (gateway when signed in, local config when BYOK)
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 }) => ({ provider: 'rowboat', model: m.id })
(m: { id: string }) => ({ flavor: '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[] = []
@ -352,12 +223,32 @@ function ChatInputInner({
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) {
models.push({ provider: flavor as ProviderName, 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,
})
}
}
}
}
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
@ -375,147 +266,6 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
// Load the global code-mode feature flag (from settings) and stay in sync.
useEffect(() => {
const load = () => {
window.ipc.invoke('codeMode:getConfig', null)
.then((r) => setCodeModeFeatureEnabled(r.enabled))
.catch(() => setCodeModeFeatureEnabled(false))
}
load()
window.addEventListener('code-mode-config-changed', load)
return () => window.removeEventListener('code-mode-config-changed', load)
}, [])
// If the feature is turned off in settings, also turn off any per-conversation chip.
useEffect(() => {
if (!codeModeFeatureEnabled && codeModeEnabled) {
setCodeModeEnabled(false)
}
}, [codeModeFeatureEnabled, codeModeEnabled])
// Cross-platform basename — handles both / and \ separators.
const basename = useCallback((p: string): string => {
const trimmed = p.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
}, [])
const rememberWorkDir = useCallback(async (dir: string) => {
const trimmed = dir.trim()
if (!trimmed) return
const next = [
{ path: trimmed, lastUsedAt: Date.now() },
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
setRecentWorkDirs(next)
await writeRecentWorkDirs(next)
}, [])
// Load coding-agent preference for a given workdir.
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
if (!dir) return 'claude'
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
const value = parsed?.[dir]
if (value === 'codex' || value === 'claude') return value
} catch {
/* file missing or invalid — fall through to default */
}
return 'claude'
}, [])
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
const existing: Record<string, 'claude' | 'codex'> = {}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
for (const [k, v] of Object.entries(parsed ?? {})) {
if (v === 'claude' || v === 'codex') existing[k] = v
}
} catch { /* start fresh */ }
existing[dir] = agent
await window.ipc.invoke('workspace:writeFile', {
path: 'config/coding-agents.json',
data: JSON.stringify(existing, null, 2),
})
}, [])
// Work directory is owned per-chat by the parent (App). This component only
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
// the work directory changes, load its persisted coding-agent preference.
useEffect(() => {
let cancelled = false
loadCodingAgentFor(workDir).then((agent) => {
if (!cancelled) setCodingAgent(agent)
})
return () => { cancelled = true }
}, [workDir, loadCodingAgentFor])
useEffect(() => {
if (isActive && workDir) void rememberWorkDir(workDir)
}, [isActive, workDir, rememberWorkDir])
const handleSetWorkDir = useCallback(async () => {
try {
let defaultPath: string | undefined = workDir ?? undefined
try {
const { root } = await window.ipc.invoke('workspace:getRoot', null)
const workspaceRel = 'knowledge/Workspace'
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
if (!exists.exists) {
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
}
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
} catch (err) {
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
}
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
title: 'Choose work directory',
defaultPath,
})
if (!chosen) return
onWorkDirChange?.(chosen)
await rememberWorkDir(chosen)
setCodingAgent(await loadCodingAgentFor(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, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
onWorkDirChange?.(dir)
await rememberWorkDir(dir)
setCodingAgent(await loadCodingAgentFor(dir))
toast.success(`Work directory set: ${dir}`)
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
const handleClearWorkDir = useCallback(() => {
onWorkDirChange?.(null)
setCodingAgent('claude')
toast.success('Work directory cleared')
}, [onWorkDirChange])
const handleToggleCodingAgent = useCallback(async () => {
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
setCodingAgent(next)
// Persist only when scoped to a workdir; without one there's nothing to key on.
if (!workDir) return
try {
await persistCodingAgent(workDir, next)
} catch (err) {
console.error('Failed to save coding agent', err)
toast.error('Failed to save coding agent')
// revert on failure
setCodingAgent(codingAgent)
}
}, [workDir, codingAgent, persistCodingAgent])
// Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
@ -534,15 +284,40 @@ function ChatInputInner({
checkSearch()
}, [isActive, isRowboatConnected])
// 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)
const handleModelChange = useCallback(async (key: string) => {
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
if (!entry) return
setActiveModelKey(key)
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
}, [configuredModels, lockedModel, onSelectedModelChange])
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])
// Restore the tab draft when this input mounts.
useEffect(() => {
@ -600,15 +375,12 @@ function ChatInputInner({
const handleSubmit = useCallback(() => {
if (!canSubmit) return
// codeMode is sticky per conversation — don't reset after send.
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
controller.textInput.clear()
controller.mentions.clearMentions()
setAttachments([])
// Web search toggle stays on for the rest of the chat session; the user
// turns it off explicitly. (Not persisted across app restarts.)
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
setSearchEnabled(false)
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -647,14 +419,8 @@ function ChatInputInner({
}
}, [addFiles, isActive])
const visibleRecentWorkDirs = recentWorkDirs
.filter((entry) => entry.path !== workDir)
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
return (
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
<div className="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) => {
@ -758,246 +524,38 @@ function ChatInputInner({
/>
</div>
<div className="flex items-center gap-2 px-4 pb-3">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<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="Add"
aria-label="Attach files"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
<div className="rounded-[14px] border border-border/80 bg-background p-1">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5">
<ImagePlus className="size-4" />
<span>Add files or photos</span>
</DropdownMenuItem>
{/* Working directory lives behind a submenu so the main menu stays to two
items. One hover/click away for power users; out of the way otherwise. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
<FolderCog className="size-4" />
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>Set working directory</span>
<span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground">
{currentWorkDirLabel}
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
{/* Current selection — shown for context only when one is set. */}
{workDir && (
<div
title={workDir}
className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
>
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
{currentWorkDirPath}
</span>
</span>
</div>
)}
{/* Primary action: choose when unset, change when set. Always on top. */}
<DropdownMenuItem
onSelect={() => { void handleSetWorkDir() }}
className="h-9 rounded-[9px] px-2.5"
>
<FolderOpen className="size-4" />
<span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span>
</DropdownMenuItem>
{visibleRecentWorkDirs.length > 0 && (
<>
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</div>
{visibleRecentWorkDirs.map((entry) => {
const name = basename(entry.path) || entry.path
const when = formatRecentWorkDirTime(entry.lastUsedAt)
return (
<DropdownMenuItem
key={entry.path}
title={entry.path}
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
className="h-8 rounded-[9px] px-2.5"
>
<FolderClock className="size-4" />
<span className="min-w-0 flex-1 truncate">{name}</span>
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
</DropdownMenuItem>
)
})}
</>
)}
{/* Clear — only meaningful once a directory is set. Kept at the bottom. */}
{workDir && (
<>
<div className="my-1 h-px bg-border/60" />
<DropdownMenuItem
onSelect={handleClearWorkDir}
className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30"
>
<X className="size-4" />
<span>Clear folder</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</div>
</DropdownMenuContent>
</DropdownMenu>
{workDir && (
<Tooltip>
<TooltipTrigger asChild>
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<button
type="button"
onClick={handleSetWorkDir}
className="flex min-w-0 items-center gap-1.5"
>
<FolderCog className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{basename(workDir) || workDir}</span>
</button>
<button
type="button"
onClick={handleClearWorkDir}
aria-label="Remove work directory"
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5 shrink-0" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Work directory: {workDir}
</TooltipContent>
</Tooltip>
)}
{searchAvailable && (
searchEnabled ? (
<button
type="button"
onClick={() => setSearchEnabled((v) => !v)}
aria-label="Search"
aria-pressed={searchEnabled}
className={cn(
'flex h-7 shrink-0 items-center rounded-full border px-1.5 transition-colors duration-150 ease-out',
searchEnabled
? 'border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
)}
onClick={() => setSearchEnabled(false)}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
>
<Globe className="h-4 w-4 shrink-0" />
<span
className={cn(
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out',
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0'
)}
>
Search
</span>
<Globe className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Search</span>
<X className="h-3 w-3" />
</button>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (runId) return
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
}}
disabled={Boolean(runId)}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
permissionMode === 'auto'
? "bg-secondary text-foreground hover:bg-secondary/70"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
)}
aria-label="Permission mode"
>
<ShieldCheck className="h-3.5 w-3.5" />
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
{runId
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
: permissionMode === 'auto'
? 'Auto-permission on — click for manual approval prompts'
: 'Manual approval prompts — click for auto-permission'}
</TooltipContent>
</Tooltip>
{codeModeFeatureEnabled && (codeModeEnabled ? (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
>
<Terminal className="h-3.5 w-3.5" />
<span>Code</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on click to disable</TooltipContent>
</Tooltip>
<span className="text-foreground/30">·</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleCodingAgent}
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} click to swap
</TooltipContent>
</Tooltip>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(true)}
onClick={() => setSearchEnabled(true)}
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="Code mode"
aria-label="Search"
>
<Terminal className="h-4 w-4" />
<Globe className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip>
))}
)
)}
<div className="flex-1" />
{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 ? (
{configuredModels.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@ -1005,7 +563,7 @@ function ChatInputInner({
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="max-w-[150px] truncate">
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
</span>
<ChevronDown className="h-3 w-3" />
</button>
@ -1013,18 +571,18 @@ function ChatInputInner({
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
{configuredModels.map((m) => {
const key = `${m.provider}/${m.model}`
const key = `${m.flavor}/${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.provider] || m.provider}</span>
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
)}
{onToggleTts && ttsAvailable && (
<div className="flex shrink-0 items-center">
<Tooltip>
@ -1149,7 +707,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
@ -1171,9 +729,6 @@ export interface ChatInputWithMentionsProps {
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
onSelectedModelChange?: (model: SelectedModel | null) => void
workDir?: string | null
onWorkDirChange?: (value: string | null) => void
}
export function ChatInputWithMentions({
@ -1202,9 +757,6 @@ export function ChatInputWithMentions({
ttsMode,
onToggleTts,
onTtsModeChange,
onSelectedModelChange,
workDir,
onWorkDirChange,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
@ -1231,9 +783,6 @@ export function ChatInputWithMentions({
ttsMode={ttsMode}
onToggleTts={onToggleTts}
onTtsModeChange={onTtsModeChange}
onSelectedModelChange={onSelectedModelChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}
/>
</PromptInputProvider>
)

View file

@ -1,21 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
import { toast } from 'sonner'
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ChatHeader } from '@/components/chat-header'
import { ChatEmptyState } from '@/components/chat-empty-state'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import {
@ -24,24 +16,19 @@ import {
MessageResponse,
} from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
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 { type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { TabBar, type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type StagedAttachment } 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 { ChatPaneSize } from '@/contexts/theme-context'
import {
type ChatViewportAnchorState,
type ChatTabViewState,
@ -51,47 +38,68 @@ import {
getWebSearchCardData,
getComposioConnectCardData,
getToolDisplayName,
groupConversationItems,
isChatMessage,
isErrorMessage,
isToolCall,
isToolGroup,
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
toToolState,
} from '@/lib/chat-conversation'
import { matchBillingError } from '@/lib/billing-error'
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]
/* ─── Billing error helpers ─── */
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLPreElement>(null)
const stickToBottom = useRef(true)
const BILLING_ERROR_PATTERNS = [
{
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
pattern: /not enough credits/i,
title: 'You\'ve run out of credits',
subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.',
cta: 'Upgrade plan',
},
{
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.',
cta: 'Reactivate',
},
] as const
function matchBillingError(message: string) {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
}
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
function BillingErrorCTA({ label }: { label: string }) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
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
window.ipc.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [])
if (!appUrl) return null
return (
<pre ref={ref} onScroll={handleScroll} className={className}>
{children}
</pre>
<button
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
>
{label}
</button>
)
}
@ -126,16 +134,13 @@ interface ChatSidebarProps {
defaultWidth?: number
isOpen?: boolean
isMaximized?: boolean
placement?: 'middle' | 'right'
paneSize?: ChatPaneSize
className?: string
chatTabs: ChatTab[]
activeChatTabId: string
getChatTabTitle: (tab: ChatTab) => string
isChatTabProcessing: (tab: ChatTab) => boolean
onSwitchChatTab: (tabId: string) => void
onCloseChatTab: (tabId: string) => void
onNewChatTab: () => void
recentRuns?: { id: string; title?: string; createdAt: string }[]
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenFullScreen?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
@ -144,7 +149,7 @@ interface ChatSidebarProps {
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
@ -153,20 +158,15 @@ interface ChatSidebarProps {
onPresetMessageConsumed?: () => void
getInitialDraft?: (tabId: string) => string | undefined
onDraftChangeForTab?: (tabId: string, text: string) => void
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
workDirByTab?: Record<string, string | null>
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
permissionResponses?: ChatTabViewState['permissionResponses']
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
onOpenKnowledgeFile?: (path: string) => void
onActivate?: () => void
collapsedLeftPaddingPx?: number
// Voice / TTS props
isRecording?: boolean
recordingText?: string
@ -187,16 +187,13 @@ export function ChatSidebar({
defaultWidth = DEFAULT_WIDTH,
isOpen = true,
isMaximized = false,
placement = 'right',
paneSize = 'chat-smaller',
className,
chatTabs,
activeChatTabId,
getChatTabTitle,
isChatTabProcessing,
onSwitchChatTab,
onCloseChatTab,
onNewChatTab,
recentRuns = [],
onSelectRun,
onOpenChatHistory,
onOpenFullScreen,
conversation,
currentAssistantMessage,
@ -214,20 +211,15 @@ export function ChatSidebar({
onPresetMessageConsumed,
getInitialDraft,
onDraftChangeForTab,
onSelectedModelChangeForTab,
workDirByTab = {},
onWorkDirChangeForTab,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
permissionResponses = new Map(),
autoPermissionDecisions = new Map(),
onPermissionResponse,
onAskHumanResponse,
isToolOpenForTab,
onToolOpenChangeForTab,
onOpenKnowledgeFile,
onActivate,
collapsedLeftPaddingPx = 196,
isRecording,
recordingText,
recordingState,
@ -242,7 +234,6 @@ 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)
@ -253,8 +244,6 @@ export function ChatSidebar({
const startWidthRef = useRef(0)
const prevIsMaximizedRef = useRef(isMaximized)
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
const isMiddlePlacement = placement === 'middle'
const isResizable = paneSize === 'chat-smaller'
const getMaxAllowedWidth = useCallback(() => {
if (typeof window === 'undefined') return MAX_WIDTH
@ -315,9 +304,7 @@ export function ChatSidebar({
setIsResizing(true)
const handleMouseMove = (event: MouseEvent) => {
const delta = isMiddlePlacement
? event.clientX - startXRef.current
: startXRef.current - event.clientX
const delta = startXRef.current - event.clientX
const maxAllowedWidth = getMaxAllowedWidth()
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
}
@ -330,7 +317,7 @@ export function ChatSidebar({
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [width, getMaxAllowedWidth, isMiddlePlacement])
}, [width, getMaxAllowedWidth])
const activeTabState = useMemo<ChatTabViewState>(() => ({
runId: runId ?? null,
@ -339,7 +326,6 @@ export function ChatSidebar({
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
}), [
runId,
conversation,
@ -347,38 +333,15 @@ export function ChatSidebar({
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
])
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getTabState = useCallback((tabId: string): ChatTabViewState => {
if (tabId === activeChatTabId) return activeTabState
return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const activeRunId = activeTabState.runId
const handleDownloadChatLog = useCallback(async () => {
if (!activeRunId) {
toast.error('No chat log available yet')
return
}
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
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,
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
) => {
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
@ -388,14 +351,7 @@ export function ChatSidebar({
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
{item.content && (
<MessageContent>
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{item.content}
</MessageResponse>
</MessageContent>
<MessageContent>{item.content}</MessageContent>
)}
</Message>
)
@ -416,12 +372,7 @@ export function ChatSidebar({
))}
</div>
)}
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{message}
</MessageResponse>
</MessageContent>
</Message>
)
@ -471,25 +422,29 @@ export function ChatSidebar({
key={item.id}
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
autoPermissionDetail={options?.autoPermissionDetail}
>
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
{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>
)
}
if (isErrorMessage(item)) {
if (matchBillingError(item.message)) {
return null
const billingError = matchBillingError(item.message)
if (billingError) {
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="space-y-2">
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
<BillingErrorCTA label={billingError.cta} />
</div>
</MessageContent>
</Message>
)
}
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
@ -512,11 +467,8 @@ export function ChatSidebar({
// not add extra width to the right and overflow the app viewport.
return { width: 0, flex: '1 1 auto' }
}
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
return { width: 0, flex: '1 1 0' }
}
return { width, flex: '0 0 auto' }
}, [isOpen, isMaximized, paneSize, width])
}, [isOpen, isMaximized, width])
return (
<div
@ -525,19 +477,16 @@ export function ChatSidebar({
onMouseDownCapture={onActivate}
onFocusCapture={onActivate}
className={cn(
'relative flex min-w-0 flex-col overflow-hidden bg-background',
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
className
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
)}
style={paneStyle}
>
{!isMaximized && isResizable && (
{!isMaximized && (
<div
onMouseDown={handleMouseDown}
className={cn(
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
'hover:after:bg-sidebar-border',
isResizing && 'after:bg-primary'
@ -547,53 +496,29 @@ export function ChatSidebar({
{showContent && (
<>
<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,
}}
>
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
<header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
<TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
getTabTitle={getChatTabTitle}
getTabId={(tab) => tab.id}
isProcessing={isChatTabProcessing}
onSwitchTab={onSwitchChatTab}
onCloseTab={onCloseChatTab}
/>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onNewChatTab}
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" />
<SquarePen className="size-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
<TooltipContent side="bottom">New chat tab</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>
@ -602,14 +527,14 @@ export function ChatSidebar({
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
>
{isMaximized
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
<TooltipContent side="bottom">
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
</TooltipContent>
</Tooltip>
)}
</header>
@ -638,64 +563,24 @@ export function ChatSidebar({
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={cn(
'mx-auto w-full max-w-4xl px-3',
tabHasConversation ? 'pb-28' : 'pb-0',
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
)}>
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
{!tabHasConversation ? (
<ChatEmptyState
wide={isMaximized}
recentRuns={recentRuns}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
<ConversationEmptyState className="h-auto">
<div className="text-sm text-muted-foreground">Ask anything...</div>
</ConversationEmptyState>
) : (
<>
{groupConversationItems(
tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.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 autoDecision = isToolCall(item)
? tabState.autoPermissionDecisions.get(item.id)
: undefined
const rendered = renderConversationItem(
item,
tab.id,
autoDecision?.decision === 'allow'
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
: undefined,
)
if (isToolCall(item)) {
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
{tabState.conversation.map((item) => {
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item) && onPermissionResponse) {
const permRequest = tabState.allPermissionRequests.get(item.id)
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
if (permRequest) {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{deniedAutoDecision && (
<AutoPermissionDecision
toolCall={deniedAutoDecision.toolCall}
permission={deniedAutoDecision.permission}
decision={deniedAutoDecision.decision}
reason={deniedAutoDecision.reason}
/>
)}
{permRequest && onPermissionResponse && (
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
@ -703,8 +588,6 @@ export function ChatSidebar({
isProcessing={isActive && isProcessing}
response={response}
/>
)}
{rendered}
</React.Fragment>
)
}
@ -749,6 +632,9 @@ export function ChatSidebar({
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-3">
{!hasConversation && (
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
)}
{chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getTabState(tab.id)
@ -776,9 +662,6 @@ 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}
workDir={workDirByTab[tab.id] ?? null}
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
isRecording={isActive && isRecording}
recordingText={isActive ? recordingText : undefined}
recordingState={isActive ? recordingState : undefined}

View file

@ -1,253 +0,0 @@
import { useMemo, useState } from 'react'
import {
CheckCircle2,
Circle,
CircleDot,
Eye,
FileText,
Loader,
Pencil,
Search,
ShieldQuestion,
Terminal,
Trash2,
Wrench,
} from 'lucide-react'
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
// ── Timeline reduction ──────────────────────────────────────────────
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
// folding tool_call + tool_call_update (by id) and the latest plan in place.
type TextRow = { kind: 'text'; id: string; text: string }
type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] }
type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] }
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
type Row = TextRow | ToolRow | PlanRow | PermRow
function reduceEvents(events: CodeRunEvent[]): Row[] {
const rows: Row[] = []
const toolIdx = new Map<string, number>()
let planIdx = -1
events.forEach((e, i) => {
switch (e.type) {
case 'message': {
if (e.role !== 'agent' || !e.text) return
const last = rows[rows.length - 1]
if (last && last.kind === 'text') last.text += e.text
else rows.push({ kind: 'text', id: `t${i}`, text: e.text })
break
}
case 'tool_call': {
const id = e.id ?? `tc${i}`
const at = toolIdx.get(id)
if (at != null) {
const r = rows[at] as ToolRow
r.title = e.title ?? r.title
r.toolKind = e.kind ?? r.toolKind
r.status = e.status ?? r.status
} else {
toolIdx.set(id, rows.length)
rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] })
}
break
}
case 'tool_call_update': {
const id = e.id ?? `tu${i}`
let at = toolIdx.get(id)
if (at == null) {
at = rows.length
toolIdx.set(id, at)
rows.push({ kind: 'tool', id, diffs: [] })
}
const r = rows[at] as ToolRow
if (e.status) r.status = e.status
for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d)
break
}
case 'plan': {
if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries
else {
planIdx = rows.length
rows.push({ kind: 'plan', id: 'plan', entries: e.entries })
}
break
}
case 'permission':
rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision })
break
default:
break
}
})
return rows
}
function toolKindIcon(kind?: string) {
switch (kind) {
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" />
case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" />
case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" />
default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" />
}
}
function planMarker(status?: string) {
if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" />
return <Circle className="size-3.5 shrink-0 text-muted-foreground" />
}
const basename = (p: string) => p.split(/[\\/]/).pop() || p
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
const rows = useMemo(() => reduceEvents(events), [events])
if (rows.length === 0) {
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent</div>
}
return (
<div className="flex flex-col gap-2 px-4 py-3">
{rows.map((row) => {
if (row.kind === 'text') {
return (
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
{row.text}
</p>
)
}
if (row.kind === 'tool') {
const running = row.status !== 'completed' && row.status !== 'failed'
return (
<div key={row.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
{running
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
{toolKindIcon(row.toolKind)}
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
</div>
{row.diffs.length > 0 && (
<div className="ml-7 flex flex-col gap-0.5">
{row.diffs.map((d) => (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
))}
</div>
)}
</div>
)
}
if (row.kind === 'plan') {
return (
<div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2">
{row.entries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm text-foreground/90">
{planMarker(entry.status)}
<span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}>
{entry.content}
</span>
</div>
))}
</div>
)
}
// resolved permission
const denied = row.decision === 'reject' || row.decision === 'cancelled'
return (
<div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}>
{denied ? '✕' : '✓'}
<span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span>
</div>
)
})}
</div>
)
}
// ── In-run permission card ──────────────────────────────────────────
export function CodeRunPermissionRequest({
ask,
onDecide,
}: {
ask: PermissionAsk
onDecide: (decision: PermissionDecision) => void
}) {
const [busy, setBusy] = useState(false)
const decide = (d: PermissionDecision) => {
if (busy) return
setBusy(true)
onDecide(d)
}
const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50'
return (
<div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldQuestion className="size-4 shrink-0 text-amber-600" />
Permission needed
</div>
<p className="mt-1 text-sm text-muted-foreground">
The agent wants to: <span className="font-medium text-foreground">{ask.title}</span>
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" disabled={busy} onClick={() => decide('allow_once')}
className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}>
Allow
</button>
<button type="button" disabled={busy} onClick={() => decide('allow_always')}
className={cn(btn, 'border hover:bg-muted')}>
Always allow{ask.kind ? ` (${ask.kind})` : ''}
</button>
<button type="button" disabled={busy} onClick={() => decide('reject')}
className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}>
Deny
</button>
</div>
</div>
)
}
// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ──
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
export function CodingRunBlock({
item,
open,
onOpenChange,
onPermissionDecision,
}: {
item: ToolCall
open: boolean
onOpenChange: (open: boolean) => void
onPermissionDecision: (decision: PermissionDecision) => void
}) {
// Prefer the agent the backend actually ran (the chip) once the run returns; fall
// back to the requested input agent while it's still in flight. Never trust only the
// model's input — it can pass a stale agent the backend overrode with the chip.
const agent =
(item.result as { agent?: string } | undefined)?.agent ??
(item.input as { agent?: string } | undefined)?.agent
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
return (
<>
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} />
</ToolContent>
</Tool>
{item.pendingCodePermission && (
<CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} />
)}
</>
)
}

View file

@ -1,78 +0,0 @@
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

@ -1,74 +0,0 @@
"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,7 +79,16 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
<Button
variant="default"
size="sm"
onClick={() => c.handleReconnect(provider)}
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)
}}
className="h-7 px-2 text-xs"
>
Reconnect

View file

@ -1,196 +0,0 @@
import { Suspense, lazy, useEffect, useRef, useState } from 'react'
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
import type { DocxEditorRef } from '@eigenpal/docx-editor-react'
// The editor (and its CSS) is heavy and only needed when a .docx is open, so it
// loads in its own chunk the first time a Word document is viewed.
const LazyDocxEditor = lazy(async () => {
const [mod] = await Promise.all([
import('@eigenpal/docx-editor-react'),
import('@eigenpal/docx-editor-react/styles.css'),
])
return { default: mod.DocxEditor }
})
interface DocxFileViewerProps {
path: string
}
type LoadState = 'loading' | 'ready' | 'error'
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
const SAVE_DEBOUNCE_MS = 800
// onChange fires for the editor's own load-time normalization. Ignore changes
// until shortly after the document settles so opening a file never rewrites it.
const ARM_DELAY_MS = 500
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64)
const len = binary.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i)
return bytes.buffer
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
}
return btoa(binary)
}
function baseName(path: string): string {
const segs = path.split('/')
return segs[segs.length - 1] || path
}
export function DocxFileViewer({ path }: DocxFileViewerProps) {
const [loadState, setLoadState] = useState<LoadState>('loading')
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
const [saveState, setSaveState] = useState<SaveState>('idle')
const editorRef = useRef<DocxEditorRef>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const armTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const armedRef = useRef(false)
const dirtyRef = useRef(false)
const savingRef = useRef(false)
// Load the .docx bytes whenever the path changes.
useEffect(() => {
let cancelled = false
setLoadState('loading')
setBuffer(null)
setSaveState('idle')
armedRef.current = false
dirtyRef.current = false
savingRef.current = false
;(async () => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'base64' })
if (cancelled) return
setBuffer(base64ToArrayBuffer(result.data))
setLoadState('ready')
if (armTimerRef.current) clearTimeout(armTimerRef.current)
armTimerRef.current = setTimeout(() => { armedRef.current = true }, ARM_DELAY_MS)
} catch (err) {
console.error('Failed to load docx:', err)
if (!cancelled) setLoadState('error')
}
})()
return () => {
cancelled = true
if (armTimerRef.current) clearTimeout(armTimerRef.current)
}
}, [path])
// Serialize the current document and write it back to disk.
const persist = async () => {
const editor = editorRef.current
if (!editor || savingRef.current) return
savingRef.current = true
dirtyRef.current = false
setSaveState('saving')
try {
const out = await editor.save()
if (out) {
await window.ipc.invoke('workspace:writeFile', {
path,
data: arrayBufferToBase64(out),
opts: { encoding: 'base64' },
})
}
setSaveState('saved')
} catch (err) {
console.error('Failed to save docx:', err)
dirtyRef.current = true
setSaveState('error')
} finally {
savingRef.current = false
// A change landed while we were saving — flush it.
if (dirtyRef.current) scheduleSave()
}
}
const scheduleSave = () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => { void persist() }, SAVE_DEBOUNCE_MS)
}
const handleChange = () => {
if (!armedRef.current) return
dirtyRef.current = true
scheduleSave()
}
// Flush a pending save when navigating away or unmounting.
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
if (dirtyRef.current) void persist()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path])
if (loadState === '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 open this document</p>
<p className="max-w-md text-xs">The file may be corrupted or not a valid Word document.</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>
)
}
if (loadState === 'loading' || !buffer) {
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" />
<p className="text-sm">Loading document</p>
</div>
)
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading editor</p>
</div>
}
>
<LazyDocxEditor
key={path}
ref={editorRef}
documentBuffer={buffer}
mode="editing"
documentName={baseName(path)}
documentNameEditable={false}
onChange={handleChange}
onError={(err) => { console.error('docx editor error:', err) }}
className="flex-1 min-h-0"
/>
</Suspense>
{saveState !== 'idle' && (
<div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'}
</div>
)}
</div>
)
}

View file

@ -29,7 +29,6 @@ import {
FileTextIcon,
FileIcon,
FileTypeIcon,
Radio,
} from 'lucide-react'
import {
DropdownMenu,
@ -43,21 +42,6 @@ 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({
@ -65,8 +49,6 @@ export function EditorToolbar({
onSelectionHighlight,
onImageUpload,
onExport,
onOpenLiveNote,
liveState,
}: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
@ -403,19 +385,6 @@ 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[], preserveRaw: string | null): string | null {
function fieldsToRaw(fields: FieldEntry[]): string | null {
const record: Record<string, string | string[]> = {}
for (const { key, value } of fields) {
if (key.trim()) record[key.trim()] = value
}
return buildFrontmatter(record, preserveRaw)
return buildFrontmatter(record)
}
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
@ -45,12 +45,10 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
}, [editingNewKey])
const commit = useCallback((updated: FieldEntry[]) => {
// 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)
const newRaw = fieldsToRaw(updated)
lastCommittedRaw.current = newRaw
onRawChange(newRaw)
}, [onRawChange, raw])
}, [onRawChange])
// For scalar fields: update local state immediately, commit on blur
const updateLocalValue = useCallback((index: number, newValue: string) => {

View file

@ -0,0 +1,100 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { MessageCircle } from "lucide-react"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
interface HelpPopoverProps {
children: React.ReactNode
tooltip?: string
}
export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
const [open, setOpen] = useState(false)
const handleDiscordClick = () => {
window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")
}
return (
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
<Tooltip open={open ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
)}
<PopoverContent
side="right"
align="end"
sideOffset={4}
className="w-80 p-0"
>
<div className="p-4 border-b">
<h4 className="font-semibold text-sm">Help & Support</h4>
<p className="text-xs text-muted-foreground mt-1">
Get help from our community
</p>
</div>
<div className="p-2">
<Button
variant="ghost"
className="w-full justify-start gap-3 h-auto py-3"
onClick={handleDiscordClick}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">
Chat with the community
</span>
</div>
</Button>
</div>
<div className="px-4 py-3 border-t flex justify-center gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -1,593 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
type RunItem = { id: string; title?: string; createdAt: string }
type TaskItem = { slug: string; name: string; active: boolean; lastRunAt?: string; lastAttemptAt?: string }
type HomeViewProps = {
tree: TreeNode[]
runs: RunItem[]
bgTaskSummaries: TaskItem[]
onOpenEmail: () => void
onOpenMeetings: () => void
onOpenAgents: () => void
onOpenAgent: (slug: string) => void
onOpenNote: (path: string) => void
onOpenRun: (runId: string) => void
onTakeMeetingNotes: () => void
onOpenChat?: () => void
}
type CalEvent = {
id: string
summary: string
start: Date
end: Date | null
isAllDay: boolean
conferenceLink: string | null
rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined
location: string | null
htmlLink: string | null
source: string
}
type RawCalEvent = {
id?: string
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
status?: string
attendees?: Array<{ self?: boolean; responseStatus?: string }>
}
type EmailThread = { threadId: string; subject: string; from: string }
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
function greeting(): string {
const h = new Date().getHours()
if (h < 12) return 'Good morning'
if (h < 18) return 'Good afternoon'
return 'Good evening'
}
function todayLabel(): string {
return new Date().toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
}
function timeOfDay(d: Date): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function relativeFromNow(start: Date): string {
const ms = start.getTime() - Date.now()
if (ms <= 0) return 'now'
const min = Math.round(ms / 60000)
if (min < 60) return `in ${min}m`
const hr = Math.round(min / 60)
if (hr < 24) return `in ${hr}h`
return start.toLocaleDateString([], { weekday: 'short' })
}
function relativeAgo(iso?: string): string {
if (!iso) return ''
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return ''
const min = Math.round((Date.now() - t) / 60000)
if (min < 1) return 'just now'
if (min < 60) return `${min}m ago`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h ago`
const d = Math.round(hr / 24)
return `${d}d ago`
}
function parseAllDay(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 normalizeCalEvent(raw: RawCalEvent, sourcePath: string): CalEvent | null {
if (raw.status === 'cancelled') return null
const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined'
if (declined) return null
const timed = raw.start?.dateTime
const allDay = raw.start?.date
const isAllDay = !timed && Boolean(allDay)
let start: Date | null = null
let end: Date | null = null
if (timed) {
start = new Date(timed)
end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null
} else if (allDay) {
start = parseAllDay(allDay)
end = raw.end?.date ? parseAllDay(raw.end.date) : null
}
if (!start || Number.isNaN(start.getTime())) return null
return {
id: raw.id ?? sourcePath,
summary: raw.summary?.trim() || '(No title)',
start,
end,
isAllDay,
conferenceLink: extractConferenceLink(raw as unknown as Record<string, unknown>) ?? null,
rawStart: raw.start,
rawEnd: raw.end,
location: raw.location?.trim() || null,
htmlLink: raw.htmlLink ?? null,
source: sourcePath,
}
}
function noteLabel(node: TreeNode): string {
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) return node.name.slice(0, -3)
return node.name
}
function triggerMeetingCapture(event: CalEvent, 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'))
}
const CARD = 'rounded-xl border border-border bg-card p-4'
const TOOLKIT_PREVIEW_LIMIT = 8
let cachedToolkitPreviews: ToolkitPreview[] | null = null
let cachedToolkitLogosLoaded = false
function ToolkitPreviewIcon({
toolkit,
onInvalid,
}: {
toolkit: ToolkitPreview
onInvalid: (slug: string) => void
}) {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<img
src={toolkit.logo}
alt=""
className="hidden"
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
setLoaded(true)
} else {
onInvalid(toolkit.slug)
}
}}
onError={() => onInvalid(toolkit.slug)}
/>
)
}
return (
<div
title={`${toolkit.name}: ${toolkit.description}`}
aria-label={toolkit.name}
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
>
<img
src={toolkit.logo}
alt=""
className="size-5 shrink-0 object-contain"
onError={() => onInvalid(toolkit.slug)}
/>
</div>
)
}
export function HomeView({
tree,
runs,
bgTaskSummaries,
onOpenEmail,
onOpenMeetings,
onOpenAgents,
onOpenAgent,
onOpenNote,
onOpenRun,
onTakeMeetingNotes,
onOpenChat,
}: HomeViewProps) {
const [events, setEvents] = useState<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const loadEvents = useCallback(async () => {
try {
const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' })
if (!exists.exists) { setEvents([]); return }
const entries = await window.ipc.invoke('workspace:readdir', {
path: 'calendar_sync',
opts: { recursive: false, includeHidden: false, includeStats: false },
})
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
const settled = await Promise.allSettled(
jsonEntries.map(async (entry): Promise<CalEvent | null> => {
const result = await window.ipc.invoke('workspace:readFile', { path: entry.path, encoding: 'utf8' })
return normalizeCalEvent(JSON.parse(result.data) as RawCalEvent, entry.path)
}),
)
const out: CalEvent[] = []
for (const r of settled) if (r.status === 'fulfilled' && r.value) out.push(r.value)
out.sort((a, b) => a.start.getTime() - b.start.getTime())
setEvents(out)
} catch (err) {
console.error('Home: failed to load events', err)
}
}, [])
const loadEmails = useCallback(async () => {
try {
const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 })
setEmails(
result.threads
.filter((t) => t.unread === true)
.slice(0, 3)
.map((t) => ({ threadId: t.threadId, subject: t.subject ?? '(No subject)', from: t.from ?? '' })),
)
} catch (err) {
console.error('Home: failed to load emails', err)
}
}, [])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
const configured = await window.ipc.invoke('composio:is-configured', null)
if (!configured.configured) return
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
const previews = toolkits.items
.filter((toolkit) => Boolean(toolkit.meta.logo))
.slice(0, TOOLKIT_PREVIEW_LIMIT)
.map((toolkit) => ({
slug: toolkit.slug,
logo: toolkit.meta.logo,
name: toolkit.name,
description: toolkit.meta.description,
}))
cachedToolkitPreviews = previews
setToolkitPreviews(previews)
} catch {
cachedToolkitPreviews = []
} finally {
cachedToolkitLogosLoaded = true
setToolkitLogosLoaded(true)
}
}, [])
const removeToolkitPreview = useCallback((slug: string) => {
setToolkitPreviews((prev) => {
const next = prev.filter((toolkit) => toolkit.slug !== slug)
cachedToolkitPreviews = next
return next
})
}, [])
useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
const now = Date.now()
return events.filter((e) => {
const end = e.end ?? (e.isAllDay ? new Date(e.start.getTime() + 864e5) : e.start)
return end.getTime() > now
})
}, [events])
const nextEvent = upcoming[0]
const todaysEvents = useMemo(() => {
const now = new Date()
return upcoming.filter((e) =>
e.start.getFullYear() === now.getFullYear() &&
e.start.getMonth() === now.getMonth() &&
e.start.getDate() === now.getDate(),
)
}, [upcoming])
const activeAgents = useMemo(() => bgTaskSummaries.filter((t) => t.active), [bgTaskSummaries])
const recentAgent = useMemo(() => {
const t = (s?: string) => (s ? new Date(s).getTime() || 0 : 0)
return [...bgTaskSummaries].sort((a, b) =>
Math.max(t(b.lastRunAt), t(b.lastAttemptAt)) - Math.max(t(a.lastRunAt), t(a.lastAttemptAt)),
)[0]
}, [bgTaskSummaries])
const recentNotes = useMemo<TreeNode[]>(() => {
const out: TreeNode[] = []
const walk = (nodes: TreeNode[]) => {
for (const n of nodes) {
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue
if (n.kind === 'file') out.push(n)
else if (n.children?.length) walk(n.children)
}
}
walk(tree)
return out
.filter((n) => n.stat?.mtimeMs)
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
.slice(0, 2)
}, [tree])
const recentActivity = useMemo(() => {
const items: Array<{ key: string; icon: 'note' | 'chat'; label: string; kind: string; when: number; open: () => void }> = []
for (const n of recentNotes) {
items.push({ key: `n:${n.path}`, icon: 'note', label: noteLabel(n), kind: 'note', when: n.stat?.mtimeMs ?? 0, open: () => onOpenNote(n.path) })
}
for (const r of runs.slice(0, 4)) {
items.push({ key: `r:${r.id}`, icon: 'chat', label: r.title || '(Untitled chat)', kind: 'chat', when: new Date(r.createdAt).getTime() || 0, open: () => onOpenRun(r.id) })
}
return items.sort((a, b) => b.when - a.when).slice(0, 4)
}, [recentNotes, runs, onOpenNote, onOpenRun])
return (
<div className="flex h-full flex-col overflow-hidden bg-muted/30">
<div className="flex-1 overflow-y-auto px-9 py-7">
<div className="mx-auto flex max-w-[760px] flex-col gap-[18px]">
{/* Greeting */}
<div className="flex items-baseline gap-3">
<h1 className="text-[28px] font-semibold tracking-tight">{greeting()}</h1>
<span className="text-sm text-muted-foreground">{todayLabel()}</span>
</div>
{/* Up-next hero */}
{nextEvent && (
<div className="flex items-center gap-[18px] rounded-xl bg-foreground p-[18px] text-background">
<div className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-background/10">
<Mic className="size-[22px]" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 text-[11px] uppercase tracking-wide text-background/55">
Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)}
</div>
<div className="mb-0.5 truncate text-[17px] font-medium">{nextEvent.summary}</div>
<div className="truncate text-[13px] text-background/70">
{nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` ${timeOfDay(nextEvent.end)}` : ''}`}
{nextEvent.location ? ` · ${nextEvent.location}` : ''}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={onTakeMeetingNotes}
className="rounded-md bg-background px-3.5 py-2 text-[13px] font-medium text-foreground"
>
Take notes
</button>
{nextEvent.conferenceLink && (
<button
type="button"
onClick={() => window.open(nextEvent.conferenceLink!, '_blank')}
className="rounded-md border border-background/20 px-3 py-2 text-background"
aria-label="Join meeting"
>
<Video className="size-[13px]" />
</button>
)}
</div>
</div>
)}
{/* Inbox + Background agents */}
<div className="grid grid-cols-2 gap-[18px]">
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Mail className="size-[15px]" />
<span className="text-sm font-medium">Inbox</span>
{emails.length > 0 && (
<span className="rounded-lg bg-destructive px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-wide text-white">
{emails.length} new
</span>
)}
<span className="flex-1" />
<button type="button" onClick={onOpenEmail} className="text-xs text-primary hover:underline">Open </button>
</div>
{emails.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No unread important email.</div>
) : emails.map((e, i) => (
<button
key={e.threadId}
type="button"
onClick={onOpenEmail}
className={`flex w-full gap-2.5 py-[7px] text-left text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<span className="w-[92px] shrink-0 truncate text-muted-foreground">{formatFrom(e.from)}</span>
<span className="flex-1 truncate">{e.subject}</span>
</button>
))}
</div>
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Bot className="size-[15px]" />
<span className="text-sm font-medium">Background agents</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">{activeAgents.length} active</span>
<button type="button" onClick={onOpenAgents} className="text-xs text-primary hover:underline">Open </button>
</div>
{recentAgent ? (
<button
type="button"
onClick={() => onOpenAgent(recentAgent.slug)}
className="flex w-full items-center gap-2.5 py-[7px] text-left text-[13px]"
>
<span className={`size-2 shrink-0 rounded-full ${recentAgent.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`} />
<span className="flex-1 truncate font-medium">{recentAgent.name}</span>
<span className="text-[11.5px] text-muted-foreground">{relativeAgo(recentAgent.lastRunAt) || '—'}</span>
</button>
) : (
<div className="py-1 text-[12.5px] text-muted-foreground">No agents yet.</div>
)}
<button
type="button"
onClick={onOpenAgents}
className="mt-3.5 flex items-center gap-2 border-t border-border pt-3 text-[12.5px] text-primary"
>
<Plus className="size-3" />
Create an agent
</button>
</div>
</div>
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">
<Calendar className="size-[14px]" />
<span className="text-sm font-medium">Today's schedule</span>
<span className="flex-1" />
<button type="button" onClick={onOpenMeetings} className="text-xs text-primary hover:underline">All meetings </button>
</div>
{todaysEvents.length === 0 ? (
<div className="py-1 text-[13px] italic text-muted-foreground">No more events today.</div>
) : todaysEvents.map((e, i) => (
<div key={e.id} className={`group flex items-center gap-3.5 py-2 text-[13px] ${i ? 'border-t border-border' : ''}`}>
<span className="w-[90px] shrink-0 font-mono text-[11.5px] text-muted-foreground">
{e.isAllDay ? 'All day' : `${timeOfDay(e.start)}${e.end ? ` ${timeOfDay(e.end)}` : ''}`}
</span>
<span className={`size-2 shrink-0 rounded-full ${i === 0 ? 'bg-emerald-500' : 'bg-border'}`} />
<span className="min-w-0 flex-1 truncate font-medium">{e.summary}</span>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => triggerMeetingCapture(e, false)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent"
>
<Mic className="size-3" />
Take notes
</button>
{e.conferenceLink && (
<button
type="button"
onClick={() => triggerMeetingCapture(e, true)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent"
>
<Video className="size-3" />
Join &amp; take notes
</button>
)}
</div>
</div>
))}
</div>
{/* Recent activity */}
{recentActivity.length > 0 && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Clock className="size-[14px]" />
<span className="text-sm font-medium">Recent activity</span>
</div>
{recentActivity.map((a, i) => (
<button
key={a.key}
type="button"
onClick={a.open}
className={`flex w-full items-center gap-3 py-2 text-left text-[13px] ${i ? 'border-t border-border' : ''}`}
>
{a.icon === 'note' ? <FileText className="size-[13px] shrink-0 text-muted-foreground" /> : <MessageSquare className="size-[13px] shrink-0 text-muted-foreground" />}
<span className="flex-1 truncate">{a.label}</span>
<span className="w-[60px] text-right text-[11px] text-muted-foreground">{a.kind}</span>
</button>
))}
</div>
)}
{/* Tool connections */}
<div className={CARD}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<span className="font-medium">Connect your tools.</span>
<span className="text-muted-foreground"> Bring context from the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
<ToolkitPreviewIcon
key={toolkit.slug}
toolkit={toolkit}
onInvalid={removeToolkitPreview}
/>
))}
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
>
Connections
<ArrowRight className="size-3" />
</button>
</div>
</div>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
{/* Open chat CTA */}
{onOpenChat && (
<button
type="button"
onClick={onOpenChat}
className="flex items-center gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground">
<MessageSquare className="size-[15px]" />
</div>
<div className="min-w-0 flex-1 text-[13.5px] leading-snug">
<span className="font-medium">Ask anything</span>
<span className="text-muted-foreground"> create presentations, do research, collaborate on docs.</span>
</div>
<span className="flex shrink-0 items-center gap-1 text-[12.5px] font-medium text-primary">
New chat
<ArrowRight className="size-3.5" />
</span>
</button>
)}
</div>
</div>
</div>
)
}
function formatFrom(from: string): string {
const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
return (m ? m[1] : from).trim()
}

View file

@ -1,127 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
const MAX_SIZE_BYTES = 5 * 1024 * 1024
type ViewerState =
| { kind: 'loading' }
| { kind: 'loaded' }
| { kind: 'empty' }
| { kind: 'tooLarge'; sizeMB: number }
| { kind: 'error'; message: string }
interface HtmlFileViewerProps {
path: string
}
function toAppWorkspaceUrl(path: string): string {
const segments = path.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
return `app://workspace/${segments.join('/')}`
}
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
const [iframeLoaded, setIframeLoaded] = useState(false)
const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path])
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
}
if (stat.size === 0) {
setState({ kind: 'empty' })
return
}
setState({ kind: 'loaded' })
} 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>
)
}
// Serve via the `app://workspace/<rel-path>` protocol so the iframe has a
// proper base URL — relative `<link>`, `<img>`, `<script>` references next
// to the file resolve correctly (the path-traversal guard in
// resolveWorkspacePath already gates the protocol handler).
return (
<div className="relative h-full w-full">
{state.kind === 'loaded' && (
<iframe
key={path}
src={iframeSrc}
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

@ -1,58 +0,0 @@
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

@ -1,803 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowLeft,
ChevronRight,
Copy,
ExternalLink,
FilePlus,
FileText,
FolderOpen,
FolderPlus,
Network,
Pencil,
SearchIcon,
Table2,
Trash2,
} from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Input } from '@/components/ui/input'
import { VoiceNoteButton } from '@/components/sidebar-content'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
export type KnowledgeViewActions = {
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => Promise<string>
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
}
type KnowledgeViewProps = {
tree: TreeNode[]
actions: KnowledgeViewActions
// Folder currently being browsed (null = root overview). Controlled by the
// app so drill-down participates in the global back/forward history.
folderPath: string | null
onNavigateFolder: (path: string | null) => void
onOpenNote: (path: string) => void
onOpenGraph: () => void
onOpenSearch: () => void
onOpenBases: () => void
onVoiceNoteCreated?: (path: string) => void
}
// Folders that have their own dedicated destinations elsewhere in the app.
const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace'])
// Theme-aware accent palette for folder avatars — colored letter on a faint
// tint of the same hue. Mirrors the design's six-colour rotation.
const AVATAR_PALETTE = [
'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400',
'bg-violet-500/10 text-violet-600 dark:text-violet-400',
'bg-amber-500/10 text-amber-600 dark:text-amber-400',
'bg-rose-500/10 text-rose-600 dark:text-rose-400',
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
'bg-sky-500/10 text-sky-600 dark:text-sky-400',
] as const
function avatarClass(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]
}
function isMarkdown(node: TreeNode): boolean {
return node.kind === 'file' && node.name.toLowerCase().endsWith('.md')
}
// All markdown notes within a node (recurses into subfolders).
function collectNotes(node: TreeNode): TreeNode[] {
if (node.kind === 'file') return isMarkdown(node) ? [node] : []
const out: TreeNode[] = []
for (const child of node.children ?? []) out.push(...collectNotes(child))
return out
}
function recentNotes(node: TreeNode, limit: number): TreeNode[] {
return collectNotes(node)
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
.slice(0, limit)
}
function latestMtime(node: TreeNode): number {
let max = node.stat?.mtimeMs ?? 0
for (const child of node.children ?? []) max = Math.max(max, latestMtime(child))
return max
}
function sortNodes(nodes: TreeNode[]): TreeNode[] {
return [...nodes].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function findNode(nodes: TreeNode[], path: string): TreeNode | null {
for (const node of nodes) {
if (node.path === path) return node
if (node.children) {
const found = findNode(node.children, path)
if (found) return found
}
}
return null
}
function formatModified(mtimeMs?: number): string {
if (!mtimeMs) return ''
const rel = formatRelativeTime(new Date(mtimeMs).toISOString())
if (!rel || rel === 'just now') return rel
return `${rel} ago`
}
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 displayName(node: TreeNode): string {
if (isMarkdown(node)) return node.name.slice(0, -3)
return node.name
}
export function KnowledgeView({
tree,
actions,
folderPath,
onNavigateFolder,
onOpenNote,
onOpenGraph,
onOpenSearch,
onOpenBases,
onVoiceNoteCreated,
}: KnowledgeViewProps) {
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const topLevel = useMemo(
() => tree.filter((n) => !HIDDEN_PATHS.has(n.path)),
[tree],
)
const folders = useMemo(
() => sortNodes(topLevel.filter((n) => n.kind === 'dir')),
[topLevel],
)
const looseNotes = useMemo(
() => sortNodes(topLevel.filter((n) => isMarkdown(n))),
[topLevel],
)
const totalNotes = useMemo(
() => topLevel.reduce((sum, n) => sum + collectNotes(n).length, 0),
[topLevel],
)
const openFolder = useCallback((path: string) => onNavigateFolder(path), [onNavigateFolder])
// When the open folder no longer exists (deleted/renamed externally), fall
// back to the root overview rather than holding a dangling drill-down.
const currentFolder = folderPath ? findNode(tree, folderPath) : null
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<p className="mt-1 text-sm text-muted-foreground">
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
<button
type="button"
onClick={() => actions.createNote(currentFolder?.path)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<FilePlus className="size-4" />
<span>New note</span>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-6">
{currentFolder ? (
<FolderDetail
folder={currentFolder}
actions={actions}
renameTarget={renameTarget}
onRequestRename={setRenameTarget}
onClearRename={() => setRenameTarget(null)}
onNavigate={onNavigateFolder}
onOpenFolder={openFolder}
onOpenNote={onOpenNote}
/>
) : (
<>
<SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" />
{folders.length === 0 ? (
<EmptyState text="No folders yet." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
{folders.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<FolderCard
node={node}
actions={actions}
renameTarget={renameTarget}
onRequestRename={setRenameTarget}
onClearRename={() => setRenameTarget(null)}
onOpenFolder={openFolder}
onOpenNote={onOpenNote}
/>
</div>
))}
</div>
)}
{looseNotes.length > 0 && (
<div className="mt-8">
<SectionHeader label={`Loose notes · ${looseNotes.length}`} />
<div className="overflow-hidden rounded-xl border border-border">
{looseNotes.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<ItemRow
node={node}
actions={actions}
renameTarget={renameTarget}
onRequestRename={setRenameTarget}
onClearRename={() => setRenameTarget(null)}
onOpenFolder={openFolder}
onOpenNote={onOpenNote}
/>
</div>
))}
</div>
</div>
)}
</>
)}
<QuickActions
actions={actions}
currentFolder={currentFolder}
onOpenBases={onOpenBases}
onFolderCreated={setRenameTarget}
/>
</div>
</div>
</div>
)
}
function QuickActions({
actions,
currentFolder,
onOpenBases,
onFolderCreated,
}: {
actions: KnowledgeViewActions
currentFolder: TreeNode | null
onOpenBases: () => void
onFolderCreated: (path: string) => void
}) {
// Inside a folder these target that folder; at the root they target knowledge/.
const parent = currentFolder?.path
return (
<div className="mt-8">
<SectionHeader label="Quick actions" />
<div className="flex flex-wrap gap-2">
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
<QuickAction
icon={FolderPlus}
label="New folder"
onClick={async () => {
try {
const path = await actions.createFolder(parent)
onFolderCreated(path)
} catch { /* ignore */ }
}}
/>
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
<QuickAction
icon={FolderOpen}
label={`Reveal in ${getFileManagerName()}`}
onClick={() => actions.revealInFileManager(parent ?? 'knowledge', true)}
/>
</div>
</div>
)
}
function SecondaryButton({
icon: Icon,
label,
onClick,
}: {
icon: typeof SearchIcon
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Icon className="size-4" />
<span>{label}</span>
</button>
)
}
function QuickAction({
icon: Icon,
label,
onClick,
}: {
icon: typeof FilePlus
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</button>
)
}
function SectionHeader({ label, aside }: { label: string; aside?: string }) {
return (
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
{aside && <span className="text-xs text-muted-foreground">{aside}</span>}
</div>
)
}
function EmptyState({ text }: { text: string }) {
return (
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground">
{text}
</div>
)
}
function FolderAvatar({ name, className }: { name: string; className?: string }) {
return (
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md text-[13px] font-bold',
avatarClass(name),
className,
)}
>
{name.charAt(0).toUpperCase() || '?'}
</div>
)
}
function FolderCard({
node,
actions,
renameTarget,
onRequestRename,
onClearRename,
onOpenFolder,
onOpenNote,
}: {
node: TreeNode
actions: KnowledgeViewActions
renameTarget: string | null
onRequestRename: (path: string) => void
onClearRename: () => void
onOpenFolder: (path: string) => void
onOpenNote: (path: string) => void
}) {
const count = useMemo(() => collectNotes(node).length, [node])
const peek = useMemo(() => recentNotes(node, 3), [node])
const modified = formatModified(latestMtime(node))
const renameActive = renameTarget === node.path
const card = (
<div
role="button"
tabIndex={0}
onClick={() => onOpenFolder(node.path)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenFolder(node.path)
}
}}
className="group flex w-full cursor-pointer items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
>
<FolderAvatar name={node.name} className="mt-0.5" />
<div className="min-w-0 flex-1">
{renameActive ? (
<RenameField
initial={node.name}
isDir
path={node.path}
actions={actions}
onDone={onClearRename}
/>
) : (
<span className="block truncate text-sm font-semibold text-foreground">
{node.name}
</span>
)}
<div className="mt-0.5 text-xs text-muted-foreground">
{count} {count === 1 ? 'note' : 'notes'}
</div>
{peek.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{peek.map((n) => (
<button
key={n.path}
type="button"
onClick={(e) => {
e.stopPropagation()
onOpenNote(n.path)
}}
className="max-w-[200px] truncate rounded-full border border-border/60 bg-muted px-2.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{displayName(n)}
</button>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{modified}
</span>
<ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
</div>
)
return (
<RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}>
{card}
</RowContextMenu>
)
}
function FolderDetail({
folder,
actions,
renameTarget,
onRequestRename,
onClearRename,
onNavigate,
onOpenFolder,
onOpenNote,
}: {
folder: TreeNode
actions: KnowledgeViewActions
renameTarget: string | null
onRequestRename: (path: string) => void
onClearRename: () => void
onNavigate: (path: string | null) => void
onOpenFolder: (path: string) => void
onOpenNote: (path: string) => void
}) {
const items = useMemo(() => sortNodes(folder.children ?? []), [folder])
// Breadcrumb segments from "knowledge/A/B" → [{ name: 'A', path }, ...].
const crumbs = useMemo(() => {
const rel = folder.path.startsWith('knowledge/')
? folder.path.slice('knowledge/'.length)
: folder.path
const parts = rel.split('/').filter(Boolean)
const out: { name: string; path: string }[] = []
let acc = 'knowledge'
for (const part of parts) {
acc = `${acc}/${part}`
out.push({ name: part, path: acc })
}
return out
}, [folder.path])
return (
<>
<div className="mb-4 flex min-w-0 items-center gap-1.5 text-sm">
<button
type="button"
onClick={() => {
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
onNavigate(parent)
}}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Back"
>
<ArrowLeft className="size-4" />
</button>
<button
type="button"
onClick={() => onNavigate(null)}
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Notes
</button>
{crumbs.map((c, i) => (
<span key={c.path} className="flex min-w-0 items-center gap-1.5">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
{i === crumbs.length - 1 ? (
<span className="truncate font-medium text-foreground">{c.name}</span>
) : (
<button
type="button"
onClick={() => onNavigate(c.path)}
className="truncate rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{c.name}
</button>
)}
</span>
))}
</div>
<SectionHeader label={`${items.length} ${items.length === 1 ? 'item' : 'items'}`} />
{items.length === 0 ? (
<EmptyState text="This folder is empty." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
{items.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<ItemRow
node={node}
actions={actions}
renameTarget={renameTarget}
onRequestRename={onRequestRename}
onClearRename={onClearRename}
onOpenFolder={onOpenFolder}
onOpenNote={onOpenNote}
/>
</div>
))}
</div>
)}
</>
)
}
function ItemRow({
node,
actions,
renameTarget,
onRequestRename,
onClearRename,
onOpenFolder,
onOpenNote,
}: {
node: TreeNode
actions: KnowledgeViewActions
renameTarget: string | null
onRequestRename: (path: string) => void
onClearRename: () => void
onOpenFolder: (path: string) => void
onOpenNote: (path: string) => void
}) {
const isDir = node.kind === 'dir'
const renameActive = renameTarget === node.path
const modified = formatModified(isDir ? latestMtime(node) : node.stat?.mtimeMs)
const count = useMemo(() => (isDir ? collectNotes(node).length : 0), [isDir, node])
const handleOpen = useCallback(() => {
if (isDir) onOpenFolder(node.path)
else onOpenNote(node.path)
}, [isDir, node.path, onOpenFolder, onOpenNote])
const row = (
<div
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen()
}
}}
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
>
{isDir ? (
<FolderAvatar name={node.name} />
) : (
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileText className="size-4" />
</div>
)}
<div className="min-w-0 flex-1">
{renameActive ? (
<RenameField
initial={displayName(node)}
isDir={isDir}
path={node.path}
actions={actions}
onDone={onClearRename}
/>
) : (
<span className="block truncate text-sm text-foreground">{displayName(node)}</span>
)}
{isDir && (
<div className="mt-0.5 text-xs text-muted-foreground">
{count} {count === 1 ? 'note' : 'notes'}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{modified}
</span>
{isDir && (
<ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
</div>
)
return (
<RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}>
{row}
</RowContextMenu>
)
}
function RenameField({
initial,
isDir,
path,
actions,
onDone,
}: {
initial: string
isDir: boolean
path: string
actions: KnowledgeViewActions
onDone: () => void
}) {
const [value, setValue] = useState(initial)
const inputRef = useRef<HTMLInputElement | null>(null)
const isSubmittingRef = useRef(false)
useEffect(() => {
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const submit = useCallback(async () => {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
const trimmed = value.trim()
if (trimmed && trimmed !== initial) {
try {
await actions.rename(path, trimmed, isDir)
toast('Renamed successfully', 'success')
} catch {
toast('Failed to rename', 'error')
}
}
onDone()
}, [actions, initial, isDir, onDone, path, value])
const cancel = useCallback(() => {
isSubmittingRef.current = true
onDone()
}, [onDone])
return (
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
void submit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancel()
}
}}
onBlur={() => {
if (!isSubmittingRef.current) void submit()
}}
className="h-7 text-sm"
/>
)
}
function RowContextMenu({
node,
actions,
onRequestRename,
children,
}: {
node: TreeNode
actions: KnowledgeViewActions
onRequestRename: (path: string) => void
children: React.ReactNode
}) {
const isDir = node.kind === 'dir'
const handleDelete = useCallback(async () => {
try {
await actions.remove(node.path)
toast('Moved to trash', 'success')
} catch {
toast('Failed to delete', 'error')
}
}, [actions, node.path])
const handleCopyPath = useCallback(() => {
actions.copyPath(node.path)
toast('Path copied', 'success')
}, [actions, node.path])
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
{isDir && (
<>
<ContextMenuItem onClick={() => actions.createNote(node.path)}>
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
<FolderPlus className="mr-2 size-4" />
New Folder
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(node.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={handleCopyPath}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(node.path, isDir)}>
<FolderOpen className="mr-2 size-4" />
Open in {getFileManagerName()}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onRequestRename(node.path)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View file

@ -1,962 +0,0 @@
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

@ -1,344 +0,0 @@
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

@ -1,5 +1,5 @@
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -7,18 +7,16 @@ import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { PromptBlockExtension } from '@/extensions/prompt-block'
import { TrackBlockExtension } from '@/extensions/track-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, EmailsBlockExtension } from '@/extensions/email-block'
import { EmailBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { Markdown } from 'tiptap-markdown'
@ -46,6 +44,31 @@ 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. We consume any adjacent newlines in the match
// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on
// its own line.
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
@ -83,8 +106,7 @@ function nodeToText(node: JsonNode): string {
return text
} else if (child.type === 'wikiLink') {
const path = (child.attrs?.path as string) || ''
const label = (child.attrs?.label as string | null | undefined) || ''
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
return path ? `[[${path}]]` : ''
} else if (child.type === 'hardBreak') {
return '\n'
}
@ -126,17 +148,6 @@ function serializeList(listNode: JsonNode, indent: number): string[] {
return lines
}
// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is
// actually invoked — the other helpers are stubs to satisfy the type.
const tableRenderHelpers: MarkdownRendererHelpers = {
renderChildren: (nodes) => {
const arr = Array.isArray(nodes) ? nodes : [nodes]
return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('')
},
wrapInBlock: (prefix, content) => prefix + content,
indent: (content) => content,
}
// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
function blockToMarkdown(node: JsonNode): string {
@ -156,14 +167,16 @@ function blockToMarkdown(node: JsonNode): string {
return serializeList(node, 0).join('\n')
case 'taskBlock':
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'promptBlock':
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackBlock':
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackTargetOpen':
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'trackTargetClose':
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'iframeBlock':
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'chartBlock':
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'tableBlock':
@ -176,8 +189,6 @@ function blockToMarkdown(node: JsonNode): string {
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'mermaidBlock':
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
case 'table':
return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim()
case 'codeBlock': {
const lang = (node.attrs?.language as string) || ''
return '```' + lang + '\n' + nodeToText(node) + '\n```'
@ -190,8 +201,7 @@ function blockToMarkdown(node: JsonNode): string {
return '---'
case 'wikiLink': {
const path = (node.attrs?.path as string) || ''
const label = (node.attrs?.label as string | null | undefined) || ''
return `[[${path}${label ? `|${label}` : ''}]]`
return `[[${path}]]`
}
case 'image': {
const src = (node.attrs?.src as string) || ''
@ -292,14 +302,12 @@ function computeWithinBlockOffset(
return 0
}
}
import { EditorToolbar, type LivePillState } from './editor-toolbar'
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
import { formatRelativeTime } from '@/lib/relative-time'
import { EditorToolbar } from './editor-toolbar'
import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css'
@ -525,106 +533,6 @@ const TabIndentExtension = Extension.create({
},
})
const slugifyHeading = (text: string) =>
text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
const decodeLinkTarget = (target: string) => {
try {
return decodeURIComponent(target)
} catch {
return target
}
}
const scrollToHeading = (view: EditorView, rawTarget: string) => {
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
if (!target) return false
const targetSlug = slugifyHeading(target)
let foundPos: number | null = null
view.state.doc.descendants((node, pos) => {
if (node.type.name !== 'heading') return true
const headingText = node.textContent.trim()
if (
headingText.toLowerCase() === target.toLowerCase()
|| slugifyHeading(headingText) === targetSlug
) {
foundPos = pos
return false
}
return true
})
if (foundPos === null) return false
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
view.dispatch(
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
)
view.focus()
const domAtPos = view.domAtPos(foundPos + 1)
const node = domAtPos.node
const headingEl = node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
return true
}
const stripMarkdownExtension = (path: string) =>
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
const isSameNotePath = (linkPath: string, notePath?: string) => {
if (!notePath) return false
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
return normalizedLink === normalizedNote
}
const isExternalHref = (href: string) =>
/^(https?:|mailto:|tel:)/i.test(href)
const collapseRelativeSegments = (relPath: string) => {
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
const stack: string[] = []
for (const part of parts) {
if (part === '..') {
if (stack.length === 0) return null
stack.pop()
} else {
stack.push(part)
}
}
return stack.join('/')
}
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
const withoutHash = href.split('#')[0]
const withoutQuery = withoutHash.split('?')[0]
const decoded = decodeLinkTarget(withoutQuery)
if (!decoded) return null
if (/^file:\/\//i.test(decoded)) {
try {
return decodeURIComponent(new URL(decoded).pathname)
} catch {
return decoded
}
}
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
const noteDir = notePath.split('/').slice(0, -1).join('/')
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
}
export interface MarkdownEditorHandle {
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
getCursorContext: () => { path: string; lineNumber: number } | null
@ -648,13 +556,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}, ref) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
// Read wikiLinks lazily inside the editor config via this ref. wikiLinks changes
// identity whenever the workspace directory tree changes (file watcher → new file
// list), and it used to be a useEditor() dependency — so any background write to
// the workspace destroyed and recreated the entire editor, resetting scroll to the
// top. Keeping it off the dep array (and reading the ref at event time) means the
// editor instance survives directory changes.
const wikiLinksRef = useRef(wikiLinks)
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
@ -677,7 +578,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Keep ref in sync with state for the plugin to access
selectionHighlightRef.current = selectionHighlight
wikiLinksRef.current = wikiLinks
// Memoize the selection highlight extension
const selectionHighlightExtension = useMemo(
@ -754,7 +654,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
heading: {
levels: [1, 2, 3],
},
link: false,
}),
Link.configure({
openOnClick: false,
@ -772,29 +671,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
PromptBlockExtension.configure({ notePath }),
TrackBlockExtension.configure({ notePath }),
TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension,
EmbedBlockExtension,
IframeBlockExtension,
ChartBlockExtension,
TableBlockExtension,
CalendarBlockExtension,
EmailsBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
MermaidBlockExtension,
WikiLink.configure({
onCreate: (path: string) => {
void wikiLinksRef.current?.onCreate?.(path)
},
onCreate: wikiLinks?.onCreate
? (path) => {
void wikiLinks.onCreate(path)
}
: undefined,
}),
TaskList,
TaskItem.configure({
nested: true,
}),
TableKit.configure({
table: { resizable: false },
}),
Placeholder.configure({
placeholder,
}),
@ -913,57 +811,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
handleClickOn: (_view, _pos, node, _nodePos, event) => {
if (node.type.name === 'wikiLink') {
event.preventDefault()
const wikiPath = String(node.attrs.path ?? '')
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
return scrollToHeading(_view, heading)
}
wikiLinksRef.current?.onOpen?.(node.attrs.path)
wikiLinks?.onOpen?.(node.attrs.path)
return true
}
return false
},
handleDOMEvents: {
click: (view, event) => {
const target = event.target as Element | null
const link = target?.closest('a[href]') as HTMLAnchorElement | null
if (!link) return false
if (link.dataset.type === 'wiki-link') return false
const href = link.getAttribute('href') ?? ''
if (!href) return false
if (href.startsWith('#')) {
event.preventDefault()
return scrollToHeading(view, href)
}
if (isExternalHref(href)) {
event.preventDefault()
window.open(href, '_blank')
return true
}
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
if (!workspacePath) return false
event.preventDefault()
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
if (result.error) console.error('Failed to open linked file:', result.error)
}).catch((err) => {
console.error('Failed to open linked file:', err)
})
return true
},
},
},
// NOTE: wikiLinks is intentionally NOT a dependency — it's read via wikiLinksRef
// at event time. Including it rebuilds the whole editor on every directory change
// (file watcher), which resets scroll to the top. See wikiLinksRef declaration.
}, [
editorSessionKey,
maybeCommitPrimaryHeading,
notePath,
preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading,
])
@ -1211,37 +1067,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Normalize for comparison (trim trailing whitespace from lines)
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
// Preserve scroll + selection across an external content sync. setContent()
// resets the selection to the top of the doc and ProseMirror scrolls it into
// view; without restoring, a background writer touching the open file (graph
// builder, live-note runner, version-history commit) yanks the viewport back
// to the top repeatedly — making the note impossible to scroll. This editor
// instance is bound to a single note path, so the prior scrollTop is always
// valid for the reloaded content.
const wrapper = wrapperRef.current
const prevScrollTop = wrapper?.scrollTop ?? 0
const hadFocus = editor.isFocused
const { from: prevFrom, to: prevTo } = editor.state.selection
isInternalUpdate.current = true
const preprocessed = preprocessMarkdown(content)
// Pre-process to preserve blank lines, then wrap track-target comment
// regions into placeholder divs so TrackTargetExtension can pick them up.
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
// Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
// Only restore the caret for a focused editor, so we never steal focus or
// scroll for a passive viewer. Clamp to the (possibly shorter) new doc.
if (hadFocus) {
const docSize = editor.state.doc.content.size
const from = Math.min(prevFrom, docSize)
const to = Math.min(prevTo, docSize)
try {
editor.chain().setMeta('addToHistory', false).setTextSelection({ from, to }).run()
} catch { /* selection no longer valid in the new doc — ignore */ }
}
isInternalUpdate.current = false
// Restore scroll last so it wins over any scrollIntoView triggered above.
if (wrapper) wrapper.scrollTop = prevScrollTop
}
}
}, [editor, content])
@ -1601,26 +1433,6 @@ 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
@ -1628,12 +1440,6 @@ 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

File diff suppressed because it is too large Load diff

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; 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 [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 [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/Calendar sync was removed — flags are seeded false and
// never flipped. Kept here so legacy gating expressions still type-check.
const [useComposioForGoogle] = useState(false)
// Composio/Gmail state
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false)
const [useComposioForGoogleCalendar] = useState(false)
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = 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; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@ -151,8 +151,25 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
setProvidersLoading(false)
}
}
// (Composio Gmail/Calendar flag fetches removed — sync was deleted.)
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)
}
}
loadProviders()
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [open])
// Load LLM models catalog on open
@ -441,8 +458,6 @@ 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,
@ -451,8 +466,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
},
model,
knowledgeGraphModel,
meetingNotesModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -605,20 +618,12 @@ 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, providerStates])
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
@ -1152,72 +1157,6 @@ 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> 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.
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
</p>
<button
onClick={handleSwitchToRowboat}
@ -221,76 +221,6 @@ 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; 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 [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 [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/Calendar sync was removed — flags are seeded false and
// never flipped. Kept here so legacy gating expressions still type-check.
const [useComposioForGoogle] = useState(false)
// Composio/Gmail state (used when signed in with Rowboat account)
const [useComposioForGoogle, setUseComposioForGoogle] = 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')
const [useComposioForGoogleCalendar] = useState(false)
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = 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; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@ -123,8 +123,25 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
setProvidersLoading(false)
}
}
// (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay 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)
}
}
loadProviders()
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [open])
// Load LLM models catalog on open
@ -418,8 +435,6 @@ 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,
@ -428,8 +443,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
},
model,
knowledgeGraphModel,
meetingNotesModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -446,7 +459,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, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
@ -522,7 +535,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
if (event.provider === 'rowboat' && event.success) {
// (Composio Gmail/Calendar flag re-check removed — sync was deleted.)
// 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)
}
setCurrentStep(2) // Go to Connect Accounts
}
})
@ -582,20 +605,12 @@ 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, providerStates])
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)

View file

@ -1,56 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,107 +0,0 @@
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: false,
}),
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 } from 'lucide-react'
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
import {
CommandDialog,
CommandInput,
@ -21,15 +21,14 @@ interface SearchResult {
path: string
}
export type SearchType = 'knowledge' | 'chat'
type SearchType = 'knowledge' | 'chat'
type Mode = 'chat' | 'search'
function activeTabToTypes(section: ActiveSection): SearchType[] {
if (section === 'knowledge') return ['knowledge']
return ['chat']
return ['chat'] // "tasks" tab maps to 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
@ -44,11 +43,12 @@ export type CommandPaletteMention = {
interface CommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
// Search mode
onSelectFile: (path: string) => void
onSelectRun: (runId: string) => void
// Overrides the sidebar-section default for the initial scope (e.g. the
// knowledge view opens search scoped to knowledge).
defaultScope?: SearchType
// Chat mode
initialContext?: CommandPaletteContext | null
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
}
export function CommandPalette({
@ -56,36 +56,63 @@ export function CommandPalette({
onOpenChange,
onSelectFile,
onSelectRun,
defaultScope,
initialContext,
onChatSubmit,
}: CommandPaletteProps) {
const { activeSection } = useSidebarSection()
const [mode, setMode] = useState<Mode>('chat')
const [chatInput, setChatInput] = useState('')
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
const chatInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
() => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))
() => new Set(activeTabToTypes(activeSection))
)
const debouncedQuery = useDebounce(query, 250)
// Sync filters and clear query when the dialog opens.
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
// and reset search filters.
useEffect(() => {
if (open) {
setQuery('')
setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)))
setMode('chat')
setChatInput('')
setContextChip(initialContext ?? null)
setActiveTypes(new Set(activeTabToTypes(activeSection)))
}
}, [open, activeSection, defaultScope])
}, [open, activeSection, initialContext])
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
// swallow it. Only fires while the dialog is open.
useEffect(() => {
if (!open) return
searchInputRef.current?.focus()
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
e.preventDefault()
e.stopPropagation()
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
}
document.addEventListener('keydown', handler, true)
return () => document.removeEventListener('keydown', handler, true)
}, [open])
// Refocus the appropriate input on mode change so the user can start typing immediately.
useEffect(() => {
if (!open) return
const target = mode === 'chat' ? chatInputRef : searchInputRef
target.current?.focus()
}, [open, mode])
const toggleType = useCallback((type: SearchType) => {
setActiveTypes(new Set([type]))
}, [])
// Search query effect (only meaningful while in search mode, but the debounce keeps running
// harmlessly otherwise — empty query skips the IPC call below).
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([])
@ -106,19 +133,25 @@ 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])
@ -131,6 +164,20 @@ 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')
@ -138,11 +185,74 @@ export function CommandPalette({
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Search across knowledge and chats"
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
showCloseButton={false}
className="top-[20%] translate-y-0"
>
{/* Mode strip */}
<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')}
icon={<FileTextIcon className="size-3" />}
label="Search"
/>
<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..."
@ -205,10 +315,48 @@ export function CommandPalette({
</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,
@ -222,19 +370,17 @@ function FilterToggle({
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
"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:text-foreground hover:bg-accent/50',
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
{icon}
<span>{label}</span>
{label}
</button>
)
}
// Back-compat export: thin alias to CommandPalette.
export const SearchDialog = CommandPalette

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import {
Dialog,
@ -11,7 +11,6 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
@ -25,9 +24,8 @@ import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
interface TabConfig {
id: ConfigTab
@ -45,10 +43,10 @@ const tabs: TabConfig[] = [
description: "Manage your Rowboat account",
},
{
id: "connections",
label: "Connections",
id: "connected-accounts",
label: "Connected Accounts",
icon: Plug,
description: "Manage accounts and tools",
description: "Manage connected services",
},
{
id: "models",
@ -71,18 +69,18 @@ const tabs: TabConfig[] = [
path: "config/security.json",
description: "Configure allowed shell commands",
},
{
id: "code-mode",
label: "Code Mode",
icon: Terminal,
description: "Delegate coding tasks to Claude Code or Codex",
},
{
id: "appearance",
label: "Appearance",
icon: Palette,
description: "Customize the look and feel",
},
{
id: "tools",
label: "Tools Library",
icon: Wrench,
description: "Browse and enable toolkits",
},
{
id: "note-tagging",
label: "Note Tagging",
@ -90,93 +88,10 @@ const tabs: TabConfig[] = [
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
{
id: "help",
label: "Help",
icon: HelpCircle,
description: "Get help and support",
},
]
interface SettingsDialogProps {
/** Optional trigger element. Omit when controlling `open` externally. */
children?: React.ReactNode
/** Tab to open on when the dialog is shown. Defaults to "account". */
defaultTab?: ConfigTab
/** Controlled open state. When provided, the dialog is fully controlled. */
open?: boolean
onOpenChange?: (open: boolean) => void
}
// --- Help & Support tab ---
function HelpSettings() {
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
</div>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">Chat with the community</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Contact us</span>
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
</div>
</Button>
<div className="flex gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</div>
)
children: React.ReactNode
}
// --- Theme option for Appearance tab ---
@ -211,7 +126,7 @@ function ThemeOption({
}
function AppearanceSettings() {
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
const { theme, setTheme } = useTheme()
return (
<div className="space-y-6">
@ -241,50 +156,6 @@ function AppearanceSettings() {
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3">Chat</h4>
<p className="text-xs text-muted-foreground mb-4">
Choose where chat sits when another pane is open
</p>
<div className="grid grid-cols-2 gap-3">
<ThemeOption
label="Chat right"
icon={PanelRight}
isSelected={chatPanePlacement === "right"}
onClick={() => setChatPanePlacement("right")}
/>
<ThemeOption
label="Chat middle"
icon={MessageCircle}
isSelected={chatPanePlacement === "middle"}
onClick={() => setChatPanePlacement("middle")}
/>
</div>
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
<p className="text-xs text-muted-foreground mb-4">
Choose how much width chat gets when another pane is open
</p>
<div className="grid grid-cols-3 gap-3">
<ThemeOption
label="Chat smaller"
icon={MessageCircle}
isSelected={chatPaneSize === "chat-smaller"}
onClick={() => setChatPaneSize("chat-smaller")}
/>
<ThemeOption
label="Chat equal"
icon={Monitor}
isSelected={chatPaneSize === "chat-equal"}
onClick={() => setChatPaneSize("chat-equal")}
/>
<ThemeOption
label="Chat bigger"
icon={PanelRight}
isSelected={chatPaneSize === "chat-bigger"}
onClick={() => setChatPaneSize("chat-bigger")}
/>
</div>
</div>
</div>
)
}
@ -322,27 +193,17 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
"openai-compatible": "http://localhost:1234/v1",
}
type ProviderModelConfig = {
apiKey: string
baseURL: string
models: string[]
knowledgeGraphModel: string
meetingNotesModel: string
liveNoteAgentModel: string
autoPermissionDecisionModel: 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, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
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 [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
@ -368,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
@ -441,9 +302,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "",
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
};
}
}
@ -460,9 +318,6 @@ 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 || "",
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
};
}
return next;
@ -536,9 +391,6 @@ 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,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -571,9 +423,6 @@ 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,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
@ -603,9 +452,6 @@ 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
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -613,7 +459,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
}))
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
@ -803,108 +649,6 @@ 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>
{/* Auto-permission model */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission 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.autoPermissionDecisionModel}
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.autoPermissionDecisionModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: 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 */}
@ -1748,255 +1492,11 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Code Mode Settings ---
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
function AgentStatusRow({
name,
installLink,
signInCommand,
status,
}: {
name: string
installLink: string
signInCommand: string
status: AgentStatus | null
}) {
const ready = status?.installed && status?.signedIn
const needsSignInOnly = status?.installed && !status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Installed
</span>
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Signed in
</span>
</div>
</div>
{ready ? (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
Ready
</span>
) : needsSignInOnly ? (
<span className="text-xs text-muted-foreground shrink-0">
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
</span>
) : (
<a
href={installLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
Install &amp; sign in
</a>
)}
</div>
)
}
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [enabled, setEnabled] = useState(false)
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
const [statusLoading, setStatusLoading] = useState(false)
const loadStatus = useCallback(async () => {
setStatusLoading(true)
try {
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
setStatus(result)
} catch {
setStatus(null)
} finally {
setStatusLoading(false)
}
}, [])
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
async function load() {
setLoading(true)
try {
const result = await window.ipc.invoke("codeMode:getConfig", null)
if (!cancelled) {
setEnabled(result.enabled)
setApprovalPolicy(result.approvalPolicy ?? 'ask')
}
} catch {
if (!cancelled) setEnabled(false)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
loadStatus()
return () => { cancelled = true }
}, [dialogOpen, loadStatus])
const handleToggle = useCallback(async (next: boolean) => {
setSaving(true)
setEnabled(next)
try {
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
window.dispatchEvent(new Event("code-mode-config-changed"))
toast.success(next ? "Code mode enabled" : "Code mode disabled")
} catch {
setEnabled(!next)
toast.error("Failed to update code mode")
} finally {
setSaving(false)
}
}, [approvalPolicy])
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
const prev = approvalPolicy
setSaving(true)
setApprovalPolicy(next)
try {
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
window.dispatchEvent(new Event("code-mode-config-changed"))
} catch {
setApprovalPolicy(prev)
toast.error("Failed to update approval policy")
} finally {
setSaving(false)
}
}, [enabled, approvalPolicy])
const anyReady = status?.claude.installed && status?.claude.signedIn
|| status?.codex.installed && status?.codex.signedIn
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-5">
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
and streams its work tool calls, file diffs, and approvals back into chat.
</p>
<p>
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
<button
onClick={() => { void loadStatus() }}
disabled={statusLoading}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
Re-check
</button>
</div>
<div className="space-y-2">
<AgentStatusRow
name="Claude Code"
installLink="https://claude.ai/code"
signInCommand="claude login"
status={status?.claude ?? null}
/>
<AgentStatusRow
name="Codex"
installLink="https://developers.openai.com/codex/cli"
signInCommand="codex login"
status={status?.codex ?? null}
/>
</div>
</div>
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Enable code mode</div>
<div className="text-xs text-muted-foreground mt-0.5">
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={saving}
/>
</div>
{enabled && (
<div className="rounded-md border px-3 py-3 space-y-2">
<div className="text-sm font-medium">Approvals</div>
<div className="text-xs text-muted-foreground">
How the coding agent checks in before changing files or running commands. You always see
everything it does in the timeline this only controls the prompts.
</div>
<Select
value={approvalPolicy}
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask every time</SelectItem>
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
</div>
</div>
)}
{enabled && status && !anyReady && (
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-amber-900 dark:text-amber-200">
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
account, then click Re-check.
</div>
</div>
)}
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = controlledOpen ?? internalOpen
const setOpen = useCallback((next: boolean) => {
if (onOpenChange) onOpenChange(next)
else setInternalOpen(next)
}, [onOpenChange])
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
@ -2004,11 +1504,6 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
const [error, setError] = useState<string | null>(null)
const [rowboatConnected, setRowboatConnected] = useState(false)
// Reset to the requested default tab each time the dialog is opened
useEffect(() => {
if (open) setActiveTab(defaultTab)
}, [open, defaultTab])
// Check if user is signed in to Rowboat
useEffect(() => {
if (!open) return
@ -2034,7 +1529,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -2100,7 +1595,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
return (
<Dialog open={open} onOpenChange={setOpen}>
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
>
@ -2142,21 +1637,11 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (
<div className="space-y-6">
<div className="space-y-2">
<h4 className="text-sm font-semibold">Primary accounts</h4>
) : activeTab === "connected-accounts" ? (
<ConnectedAccountsSettings dialogOpen={open} />
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Library</h4>
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
</div>
</div>
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
@ -2165,10 +1650,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "help" ? (
<HelpSettings />
) : activeTab === "code-mode" ? (
<CodeModeSettings dialogOpen={open} />
) : activeTab === "tools" ? (
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -17,44 +17,11 @@ import {
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner"
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
interface AccountSettingsProps {
dialogOpen: boolean
}
function formatPlanName(plan: string | null | undefined) {
if (!plan) return 'No Plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
}
function CreditUsageBar({ label, bucket, helper }: {
label: string
bucket: BillingUsageBucket
helper?: string
}) {
const pct = bucket.sanctionedCredits > 0
? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100)))
: 0
return (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
{helper ? <p className="text-[11px] text-muted-foreground">{helper}</p> : null}
</div>
<p className="shrink-0 text-xs font-medium tabular-nums">
{pct}%
</p>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
)
}
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [connectionLoading, setConnectionLoading] = useState(true)
@ -62,7 +29,6 @@ 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 {
@ -197,7 +163,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{formatPlanName(billing.subscriptionPlan)}
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -214,17 +180,9 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
</Button>
</div>
<div className="space-y-3 border-t pt-3">
<CreditUsageBar label="Plan usage" bucket={billing.monthly} />
<CreditUsageBar
label="Daily use"
bucket={billing.daily}
helper="Daily usage resets at 00:00 UTC"
/>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
@ -245,15 +203,15 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<Button
variant="outline"
size="sm"
disabled={!hasPaidSubscription}
disabled={!billing?.subscriptionPlan}
onClick={() => appUrl && window.open(appUrl)}
className="gap-1.5"
>
<ExternalLink className="size-3" />
Manage in Stripe
</Button>
{!hasPaidSubscription && (
<p className="text-[11px] text-muted-foreground">Upgrade to a paid plan first</p>
{!billing?.subscriptionPlan && (
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
)}
</div>

View file

@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
return (
<div
key={provider}
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
@ -52,7 +52,16 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
<Button
variant="default"
size="sm"
onClick={() => c.handleReconnect(provider)}
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)
}}
className="h-7 px-3 text-xs"
>
Reconnect
@ -119,15 +128,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{/* Email & Calendar Section */}
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
<>
<div className="px-3 pt-1 pb-0.5">
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
</div>
{c.useComposioForGoogle ? (
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col min-w-0">
@ -174,9 +183,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
@ -220,14 +229,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
</div>
</div>
)}
<Separator className="my-2" />
<Separator className="my-3" />
</>
)}
{/* Meeting Notes Section */}
{c.providers.includes('fireflies-ai') && (
<>
<div className="px-3 pt-1 pb-0.5">
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,7 @@ export function TabBar<T>({
return (
<div
className={cn(
'rowboat-tabbar flex flex-1 self-stretch min-w-0',
'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(
'rowboat-tab titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
'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

@ -1,24 +0,0 @@
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

@ -0,0 +1,522 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { z } from 'zod'
import '@/styles/track-modal.css'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
Trash2, ChevronDown, ChevronUp,
} from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
import type { OpenTrackModalDetail } from '@/extensions/track-block'
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
// ---------------------------------------------------------------------------
// Schedule helpers
// ---------------------------------------------------------------------------
const CRON_PHRASES: Record<string, string> = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Hourly',
'0 */2 * * *': 'Every 2 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 8 * * *': 'Daily at 8 AM',
'0 9 * * *': 'Daily at 9 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6 PM',
'0 9 * * 1-5': 'Weekdays at 9 AM',
'0 17 * * 1-5': 'Weekdays at 5 PM',
'0 0 * * 0': 'Sundays at midnight',
'0 0 * * 1': 'Mondays at midnight',
'0 0 1 * *': 'First of each month',
}
function describeCron(expr: string): string {
return CRON_PHRASES[expr.trim()] ?? expr
}
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
if (schedule.type === 'once') {
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
}
if (schedule.type === 'cron') {
return { icon: 'timer', text: describeCron(schedule.expression) }
}
if (schedule.type === 'window') {
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}${schedule.endTime}` }
}
return { icon: 'calendar', text: 'Scheduled' }
}
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
if (icon === 'timer') return <Clock size={size} />
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
return <Zap size={size} />
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
type Tab = 'what' | 'when' | 'event' | 'details'
export function TrackModal() {
const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
const [yaml, setYaml] = useState<string>('')
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<Tab>('what')
const [editingRaw, setEditingRaw] = useState(false)
const [rawDraft, setRawDraft] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Listen for the open event and seed modal state.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<OpenTrackModalDetail>
const d = ev.detail
if (!d?.trackId || !d?.filePath) return
setDetail(d)
setYaml(d.initialYaml ?? '')
setActiveTab('what')
setEditingRaw(false)
setRawDraft('')
setShowAdvanced(false)
setConfirmingDelete(false)
setError(null)
setOpen(true)
void fetchFresh(d)
}
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
try {
setLoading(true)
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
if (res?.success && res.yaml) {
setYaml(res.yaml)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
if (!yaml) return null
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
}, [yaml])
const trackId = track?.trackId ?? detail?.trackId ?? ''
const instruction = track?.instruction ?? ''
const active = track?.active ?? true
const schedule = track?.schedule
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const lastRunAt = track?.lastRunAt ?? ''
const lastRunId = track?.lastRunId ?? ''
const lastRunSummary = track?.lastRunSummary ?? ''
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
const triggerType: 'scheduled' | 'event' | 'manual' =
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
const allTrackStatus = useTrackStatus()
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
useEffect(() => {
if (editingRaw && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [editingRaw])
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
{ key: 'what', label: 'What to track', visible: true },
{ key: 'when', label: 'When to run', visible: !!schedule },
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
{ key: 'details', label: 'Details', visible: true },
]
const shown = visibleTabs.filter(t => t.visible)
useEffect(() => {
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule, eventMatchCriteria])
// -------------------------------------------------------------------------
// IPC-backed mutations
// -------------------------------------------------------------------------
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:update', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
updates,
})
if (res?.success && res.yaml) {
setYaml(res.yaml)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail])
const handleToggleActive = useCallback(() => {
void runUpdate({ active: !active })
}, [active, runUpdate])
const handleRun = useCallback(async () => {
if (!detail || isRunning) return
try {
await window.ipc.invoke('track:run', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
})
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [detail, isRunning])
const handleSaveRaw = useCallback(async () => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:replaceYaml', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
yaml: rawDraft,
})
if (res?.success && res.yaml) {
setYaml(res.yaml)
setEditingRaw(false)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail, rawDraft])
const handleDelete = useCallback(async () => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:delete', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
})
if (res?.success) {
// Tell the editor to remove the node so Tiptap's next save doesn't
// re-create the track block on disk.
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
setOpen(false)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail])
const handleEditWithCopilot = useCallback(() => {
if (!detail) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
detail: {
trackId: detail.trackId,
filePath: detail.filePath,
},
}))
setOpen(false)
}, [detail])
if (!detail) return null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
data-trigger={triggerType}
data-active={active ? 'true' : 'false'}
>
<div className="track-modal-header">
<div className="track-modal-header-left">
<div className="track-modal-icon-wrap">
<Radio size={16} />
</div>
<div className="track-modal-title-col">
<DialogHeader className="space-y-0">
<DialogTitle className="track-modal-title">
{trackId || 'Track'}
</DialogTitle>
<DialogDescription className="track-modal-subtitle">
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
{scheduleSummary.text}
{eventMatchCriteria && triggerType === 'scheduled' && (
<span className="track-modal-subtitle-sep">· also event-driven</span>
)}
</DialogDescription>
</DialogHeader>
</div>
</div>
<div className="track-modal-header-actions">
<label className="track-modal-toggle">
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
</label>
</div>
</div>
{/* Tabs */}
<div className="track-modal-tabs">
{shown.map(tab => (
<button
key={tab.key}
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
</div>
{/* Body */}
<div className="track-modal-body">
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest</div>}
{activeTab === 'what' && (
<div className="track-modal-prose">
{instruction
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
: <span className="track-modal-empty">No instruction set.</span>}
</div>
)}
{activeTab === 'when' && schedule && (
<div className="track-modal-when">
<div className="track-modal-when-headline">
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
<span>{scheduleSummary.text}</span>
</div>
<dl className="track-modal-dl">
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
{schedule.type === 'cron' && (
<>
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
</>
)}
{schedule.type === 'window' && (
<>
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
<dt>Window</dt><dd>{schedule.startTime} {schedule.endTime}</dd>
</>
)}
{schedule.type === 'once' && (
<>
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
</>
)}
</dl>
</div>
)}
{activeTab === 'event' && (
<div className="track-modal-prose">
{eventMatchCriteria
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
: <span className="track-modal-empty">No event matching set.</span>}
</div>
)}
{activeTab === 'details' && (
<div className="track-modal-details">
<dl className="track-modal-dl">
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
{lastRunAt && (<>
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
</>)}
{lastRunId && (<>
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
</>)}
{lastRunSummary && (<>
<dt>Summary</dt><dd>{lastRunSummary}</dd>
</>)}
</dl>
</div>
)}
{/* Advanced (raw YAML) — all tabs */}
<div className="track-modal-advanced">
<button
className="track-modal-advanced-toggle"
onClick={() => {
const next = !showAdvanced
setShowAdvanced(next)
if (next) {
setRawDraft(yaml)
setEditingRaw(true)
} else {
setEditingRaw(false)
}
}}
>
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<Code2 size={12} />
Advanced (raw YAML)
</button>
{showAdvanced && (
<div className="track-modal-raw-editor">
<Textarea
ref={textareaRef}
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={12}
spellCheck={false}
className="track-modal-textarea"
/>
<div className="track-modal-raw-actions">
<Button
variant="outline"
size="sm"
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveRaw}
disabled={saving || rawDraft.trim() === yaml.trim()}
>
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
Save
</Button>
</div>
</div>
)}
</div>
{/* Danger zone — on Details tab only */}
{activeTab === 'details' && (
<div className="track-modal-danger-zone">
{confirmingDelete ? (
<div className="track-modal-confirm">
<span>Delete this track and its generated content?</span>
<div className="track-modal-confirm-actions">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
Yes, delete
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="track-modal-delete-btn"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 size={12} />
Delete track block
</Button>
)}
</div>
)}
</div>
{error && (
<div className="track-modal-error">{error}</div>
)}
<DialogFooter className="track-modal-footer">
<Button
variant="outline"
size="sm"
onClick={handleEditWithCopilot}
disabled={saving}
>
<Sparkles size={12} />
Edit with Copilot
</Button>
<Button
size="sm"
onClick={handleRun}
disabled={isRunning || saving}
className="track-modal-run-btn"
>
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{isRunning ? 'Running…' : 'Run now'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function stripKnowledgePrefix(p: string): string {
return p.replace(/^knowledge\//, '')
}

View file

@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)

View file

@ -1,149 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,576 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
ExternalLink,
File as FileIcon,
FilePlus,
Folder as FolderIcon,
FolderOpen,
FolderPlus,
Home,
Pencil,
Plus,
Trash2,
UploadCloud,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
const WORKSPACE_ROOT = 'knowledge/Workspace'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
}
type WorkspaceActions = {
remove: (path: string) => Promise<void>
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => Promise<string>
onOpenInNewTab?: (path: string) => void
}
type WorkspaceViewProps = {
tree: TreeNode[]
initialPath?: string | null
actions: WorkspaceActions
// Folder currently being browsed. Controlled by the app so drill-down
// participates in the global back/forward history.
onNavigate: (path: string) => void
onOpenNote: (path: string) => void
onCreateWorkspace: (name: string) => Promise<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 fileExtensionLabel(name: string): string {
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return 'File'
return `${name.slice(dot + 1).toUpperCase()} file`
}
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
if (!nodes) return null
for (const node of nodes) {
if (node.path === path) return node
if (node.kind === 'dir' && path.startsWith(`${node.path}/`)) {
const found = findNode(node.children, path)
if (found) return found
}
}
return null
}
function countChildren(node: TreeNode | null): number {
if (!node || node.kind !== 'dir' || !node.children) return 0
return node.children.length
}
async function uniqueChildPath(parent: string, name: string): Promise<string> {
const dot = name.lastIndexOf('.')
const base = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
let candidate = `${parent}/${name}`
let i = 1
while ((await window.ipc.invoke('workspace:exists', { path: candidate })).exists) {
candidate = `${parent}/${base} (${i})${ext}`
i += 1
}
return candidate
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
resolve(result.split(',')[1] ?? '')
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
const currentPath = initialPath || WORKSPACE_ROOT
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [uploading, setUploading] = useState(false)
const dragDepthRef = useRef(0)
const filesInputRef = useRef<HTMLInputElement | null>(null)
const folderInputRef = useRef<HTMLInputElement | null>(null)
const isRoot = currentPath === WORKSPACE_ROOT
const fileManagerName = getFileManagerName()
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
const items = useMemo<TreeNode[]>(() => {
const children = currentNode?.children ?? []
const filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children
return [...filtered].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}, [currentNode, isRoot])
const breadcrumbs = useMemo(() => {
if (isRoot) return [] as { path: string; name: string }[]
const rel = currentPath.slice(WORKSPACE_ROOT.length + 1)
const parts = rel.split('/').filter(Boolean)
let acc = WORKSPACE_ROOT
return parts.map((seg) => {
acc = `${acc}/${seg}`
return { path: acc, name: seg }
})
}, [currentPath, isRoot])
const handleItemClick = useCallback(
(item: TreeNode) => {
if (renameTarget) return
if (item.kind === 'dir') {
onNavigate(item.path)
} else {
onOpenNote(item.path)
}
},
[onNavigate, onOpenNote, renameTarget],
)
const beginRename = useCallback((item: TreeNode) => {
setRenameTarget(item.path)
setRenameValue(item.name)
}, [])
const commitRename = useCallback(async () => {
if (!renameTarget) return
const node = items.find((i) => i.path === renameTarget)
const trimmed = renameValue.trim()
setRenameTarget(null)
if (!node || !trimmed || trimmed === node.name || trimmed.includes('/')) return
const parent = renameTarget.slice(0, renameTarget.lastIndexOf('/'))
try {
await window.ipc.invoke('workspace:rename', { from: renameTarget, to: `${parent}/${trimmed}` })
toast('Renamed', 'success')
} catch {
toast('Failed to rename', 'error')
}
}, [renameTarget, renameValue, items])
const handleDelete = useCallback(async (item: TreeNode) => {
try {
await actions.remove(item.path)
toast('Moved to trash', 'success')
} catch {
toast('Failed to delete', 'error')
}
}, [actions])
const uploadFiles = useCallback(async (files: FileList | File[], preserveStructure = false) => {
const list = Array.from(files)
if (list.length === 0) return
setUploading(true)
try {
for (const file of list) {
const data = await readFileAsBase64(file)
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath
const target = preserveStructure && rel
? `${currentPath}/${rel}`
: await uniqueChildPath(currentPath, file.name)
await window.ipc.invoke('workspace:writeFile', {
path: target,
data,
opts: { encoding: 'base64', mkdirp: true },
})
}
toast(list.length === 1 ? 'Added' : `${list.length} items added`, 'success')
} catch (err) {
console.error('Failed to add files:', err)
toast('Failed to add', 'error')
} finally {
setUploading(false)
}
}, [currentPath])
// Drag-and-drop (only inside a workspace folder, not at the root grid).
// stopPropagation keeps the drop from also reaching the copilot's
// document-level drop listener when it lands on the workspace area.
const dropEnabled = !isRoot
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
if (!Array.from(e.dataTransfer.types).includes('Files')) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current += 1
setIsDraggingOver(true)
}, [dropEnabled])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
if (!Array.from(e.dataTransfer.types).includes('Files')) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [dropEnabled])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current -= 1
if (dragDepthRef.current <= 0) {
dragDepthRef.current = 0
setIsDraggingOver(false)
}
}, [dropEnabled])
const handleDrop = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current = 0
setIsDraggingOver(false)
if (e.dataTransfer.files?.length) void uploadFiles(e.dataTransfer.files)
}, [dropEnabled, uploadFiles])
const resetAddDialog = useCallback(() => {
setNewName('')
setError(null)
setCreating(false)
}, [])
const handleCreate = useCallback(async () => {
const trimmed = newName.trim()
if (!trimmed) {
setError('Name is required')
return
}
if (trimmed.includes('/')) {
setError('Name cannot contain "/"')
return
}
setCreating(true)
setError(null)
try {
await onCreateWorkspace(trimmed)
setAddOpen(false)
resetAddDialog()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create workspace')
setCreating(false)
}
}, [newName, onCreateWorkspace, resetAddDialog])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex min-w-0 items-center gap-1 text-sm">
<button
type="button"
onClick={() => onNavigate(WORKSPACE_ROOT)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<Home className="size-4" />
<span className="font-medium">Workspace</span>
</button>
{breadcrumbs.map((crumb, idx) => {
const isLast = idx === breadcrumbs.length - 1
return (
<span key={crumb.path} className="flex items-center gap-1">
<ChevronRight className="size-4 text-muted-foreground/60" />
{isLast ? (
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
{crumb.name}
</span>
) : (
<button
type="button"
onClick={() => onNavigate(crumb.path)}
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
>
{crumb.name}
</button>
)}
</span>
)
})}
</div>
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => actions.revealInFileManager(currentPath, true)}
>
<FolderOpen className="size-4" />
Open in {fileManagerName}
</Button>
{isRoot ? (
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="w-full">
<Plus className="size-4" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
<FilePlus className="mr-2 size-4" />
Add files
</DropdownMenuItem>
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderPlus className="mr-2 size-4" />
Add folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<input
ref={filesInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void uploadFiles(e.target.files, false)
e.target.value = ''
}}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error non-standard but supported in Chromium/Electron
webkitdirectory=""
directory=""
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void uploadFiles(e.target.files, true)
e.target.value = ''
}}
/>
<div
className="relative flex-1 overflow-y-auto px-6 py-6"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
<FolderIcon className="size-10 opacity-50" />
<div className="text-sm">
{isRoot
? 'No workspaces yet. Create one to get started.'
: 'This folder is empty. Drag files in or use New note / New folder.'}
</div>
{isRoot && (
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
)}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
{items.map((item) => {
const childCount = item.kind === 'dir' ? countChildren(item) : 0
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
const isRenaming = renameTarget === item.path
const card = (
<button
type="button"
onClick={() => handleItemClick(item)}
className="group flex w-full flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
>
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
<div className="min-w-0 w-full">
{isRenaming ? (
<Input
autoFocus
value={renameValue}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') { e.preventDefault(); void commitRename() }
else if (e.key === 'Escape') { e.preventDefault(); setRenameTarget(null) }
}}
className="h-6 text-sm"
/>
) : (
<div className="truncate text-sm font-medium">{item.name}</div>
)}
{!isRenaming && (
<div className="truncate text-xs text-muted-foreground">
{item.kind === 'dir'
? `${childCount} ${childCount === 1 ? 'item' : 'items'}`
: fileExtensionLabel(item.name)}
</div>
)}
</div>
</button>
)
const isDir = item.kind === 'dir'
return (
<ContextMenu key={item.path}>
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
{isDir && (
<>
<ContextMenuItem onClick={() => actions.createNote(item.path)}>
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => void actions.createFolder(item.path)}>
<FolderPlus className="mr-2 size-4" />
New Folder
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}>
<FolderOpen className="mr-2 size-4" />
Open in {fileManagerName}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => beginRename(item)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
{dropEnabled && isDraggingOver && (
<div className="pointer-events-none absolute inset-3 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5 text-primary">
<UploadCloud className="size-8" />
<span className="text-sm font-medium">Drop files to add to this folder</span>
</div>
)}
{uploading && (
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-md bg-foreground/80 px-3 py-1.5 text-xs text-background">
Adding files
</div>
)}
</div>
<Dialog
open={addOpen}
onOpenChange={(open) => {
setAddOpen(open)
if (!open) resetAddDialog()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
<DialogDescription>
Workspaces are top-level folders inside knowledge/Workspace.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label htmlFor="workspace-name" className="text-sm font-medium">Name</label>
<Input
id="workspace-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Alpha"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !creating) {
e.preventDefault()
void handleCreate()
}
}}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setAddOpen(false)
resetAddDialog()
}}
disabled={creating}
>
Cancel
</Button>
<Button onClick={() => void handleCreate()} disabled={creating || !newName.trim()}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -3,32 +3,16 @@
import * as React from "react"
export type Theme = "light" | "dark" | "system"
export type ChatPanePlacement = "right" | "middle"
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
type ThemeContextProps = {
theme: Theme
resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void
chatPanePlacement: ChatPanePlacement
setChatPanePlacement: (placement: ChatPanePlacement) => void
chatPaneSize: ChatPaneSize
setChatPaneSize: (size: ChatPaneSize) => void
}
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
const STORAGE_KEY = "rowboat-theme"
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
return value === "right" || value === "middle"
}
function isChatPaneSize(value: string | null): value is ChatPaneSize {
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
}
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light"
@ -55,16 +39,6 @@ export function ThemeProvider({
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
return stored || defaultTheme
})
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
if (typeof window === "undefined") return "right"
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
return isChatPanePlacement(stored) ? stored : "right"
})
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
if (typeof window === "undefined") return "chat-smaller"
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
return isChatPaneSize(stored) ? stored : "chat-smaller"
})
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
if (theme === "system") return getSystemTheme()
@ -102,27 +76,13 @@ export function ThemeProvider({
setThemeState(newTheme)
}, [])
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
setChatPanePlacementState(placement)
}, [])
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
setChatPaneSizeState(size)
}, [])
const contextValue = React.useMemo<ThemeContextProps>(
() => ({
theme,
resolvedTheme,
setTheme,
chatPanePlacement,
setChatPanePlacement,
chatPaneSize,
setChatPaneSize,
}),
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
[theme, resolvedTheme, setTheme]
)
return (

View file

@ -3,7 +3,6 @@ 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)
@ -41,6 +40,25 @@ 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, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context'
@ -11,47 +11,17 @@ function formatEmailDate(dateStr: string): string {
try {
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
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' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} catch {
return dateStr
}
}
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]
/** Extract just the name part from "Name <email>" format */
function senderFirstName(from: string): string {
const name = from.replace(/<.*>/, '').trim()
return name.split(/\s+/)[0] || name
}
declare global {
@ -60,307 +30,7 @@ declare global {
}
}
// --- 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) ---
// --- Email Block ---
function EmailBlockView({ node, deleteNode, updateAttributes }: {
node: { attrs: Record<string, unknown> }
@ -372,58 +42,195 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
try {
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
} catch { /* fallback below */ }
} catch {
// fallback below
}
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
const { resolvedTheme } = useTheme()
const [expanded, setExpanded] = useState(false)
void updateAttributes // available for future per-email draft persistence
// 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])
if (!config) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
<div className="email-block-card email-block-error">
<Mail size={16} />
<span>Invalid email block</span>
</div>
</NodeViewWrapper>
)
}
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() : '')
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')
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>
<div
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
{/* Header: Email badge */}
<div className="email-block-badge">
<Mail size={13} />
Email
</div>
{/* Summary */}
<div className="email-block-summary">{summary}</div>
{/* Expandable email details */}
<button
className="email-block-expand-btn"
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<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>
</div>
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
</div>
<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>
{expanded && (
<EmailExpandedBody
config={config}
resolvedTheme={resolvedTheme}
/>
{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>
{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>
)}
</div>
</div>
</NodeViewWrapper>
)
}
@ -436,7 +243,9 @@ export const EmailBlockExtension = Node.create({
draggable: false,
addAttributes() {
return { data: { default: '{}' } }
return {
data: { default: '{}' },
}
},
parseHTML() {
@ -447,7 +256,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') && !cls.includes('language-emails')) {
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
return { data: code.textContent || '{}' }
}
return false

View file

@ -1,7 +1,6 @@
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 {
@ -25,28 +24,6 @@ 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
@ -68,7 +45,6 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
)
}
const tweetId = extractTweetId(config.url)
const embedUrl = getEmbedUrl(config.provider, config.url)
return (
@ -81,14 +57,7 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
>
<X size={14} />
</button>
{config.provider === 'tweet' && tweetId ? (
<div
className="embed-block-tweet-shell"
onMouseDown={(event) => event.stopPropagation()}
>
<Tweet id={tweetId} />
</div>
) : embedUrl ? (
{embedUrl ? (
<div className="embed-block-iframe-container">
<iframe
src={embedUrl}

View file

@ -1,256 +0,0 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Globe, X } from 'lucide-react'
import { blocks } from '@x/shared'
import { useEffect, useRef, useState } from 'react'
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height'
const IFRAME_HEIGHT_CACHE_PREFIX = 'rowboat:iframe-height:'
const DEFAULT_IFRAME_HEIGHT = 560
const MIN_IFRAME_HEIGHT = 240
const HEIGHT_UPDATE_THRESHOLD = 4
const AUTO_RESIZE_SETTLE_MS = 160
const LOAD_FALLBACK_READY_MS = 180
const DEFAULT_IFRAME_ALLOW = [
'accelerometer',
'autoplay',
'camera',
'clipboard-read',
'clipboard-write',
'display-capture',
'encrypted-media',
'fullscreen',
'geolocation',
'microphone',
].join('; ')
function getIframeHeightCacheKey(url: string): string {
return `${IFRAME_HEIGHT_CACHE_PREFIX}${url}`
}
function readCachedIframeHeight(url: string, fallbackHeight: number): number {
try {
const raw = window.localStorage.getItem(getIframeHeightCacheKey(url))
if (!raw) return fallbackHeight
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return fallbackHeight
return Math.max(MIN_IFRAME_HEIGHT, parsed)
} catch {
return fallbackHeight
}
}
function writeCachedIframeHeight(url: string, height: number): void {
try {
window.localStorage.setItem(getIframeHeightCacheKey(url), String(height))
} catch {
// ignore storage failures
}
}
function parseIframeHeightMessage(event: MessageEvent): { height: number } | null {
const data = event.data
if (!data || typeof data !== 'object') return null
const candidate = data as { type?: unknown; height?: unknown }
if (candidate.type !== IFRAME_HEIGHT_MESSAGE) return null
if (typeof candidate.height !== 'number' || !Number.isFinite(candidate.height)) return null
return {
height: Math.max(MIN_IFRAME_HEIGHT, Math.ceil(candidate.height)),
}
}
function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.IframeBlock | null = null
try {
config = blocks.IframeBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
<div className="iframe-block-card iframe-block-error">
<Globe size={16} />
<span>Invalid iframe block</span>
</div>
</NodeViewWrapper>
)
}
const visibleTitle = config.title?.trim() || ''
const title = visibleTitle || 'Embedded page'
const allow = config.allow || DEFAULT_IFRAME_ALLOW
const initialHeight = config.height ?? DEFAULT_IFRAME_HEIGHT
const [frameHeight, setFrameHeight] = useState(() => readCachedIframeHeight(config.url, initialHeight))
const [frameReady, setFrameReady] = useState(false)
const iframeRef = useRef<HTMLIFrameElement | null>(null)
const loadFallbackTimerRef = useRef<number | null>(null)
const autoResizeReadyTimerRef = useRef<number | null>(null)
const frameReadyRef = useRef(false)
useEffect(() => {
setFrameHeight(readCachedIframeHeight(config.url, initialHeight))
setFrameReady(false)
frameReadyRef.current = false
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
loadFallbackTimerRef.current = null
}
if (autoResizeReadyTimerRef.current !== null) {
window.clearTimeout(autoResizeReadyTimerRef.current)
autoResizeReadyTimerRef.current = null
}
}, [config.url, initialHeight, raw])
useEffect(() => {
frameReadyRef.current = frameReady
}, [frameReady])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (!iframeWindow || event.source !== iframeWindow) return
const message = parseIframeHeightMessage(event)
if (!message) return
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
loadFallbackTimerRef.current = null
}
if (autoResizeReadyTimerRef.current !== null) {
window.clearTimeout(autoResizeReadyTimerRef.current)
}
writeCachedIframeHeight(config.url, message.height)
setFrameHeight((currentHeight) => (
Math.abs(currentHeight - message.height) < HEIGHT_UPDATE_THRESHOLD ? currentHeight : message.height
))
if (!frameReadyRef.current) {
autoResizeReadyTimerRef.current = window.setTimeout(() => {
setFrameReady(true)
frameReadyRef.current = true
autoResizeReadyTimerRef.current = null
}, AUTO_RESIZE_SETTLE_MS)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [config.url])
useEffect(() => {
return () => {
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
}
if (autoResizeReadyTimerRef.current !== null) {
window.clearTimeout(autoResizeReadyTimerRef.current)
}
}
}, [])
return (
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
<div className="iframe-block-card">
<button
className="iframe-block-delete"
onClick={deleteNode}
aria-label="Delete iframe block"
>
<X size={14} />
</button>
{visibleTitle && <div className="iframe-block-title">{visibleTitle}</div>}
<div
className={`iframe-block-frame-shell${frameReady ? ' iframe-block-frame-shell-ready' : ' iframe-block-frame-shell-loading'}`}
style={{ height: frameHeight }}
>
{!frameReady && (
<div className="iframe-block-loading-overlay" aria-hidden="true">
<div className="iframe-block-loading-bar" />
<div className="iframe-block-loading-copy">Loading embed</div>
</div>
)}
<iframe
ref={iframeRef}
src={config.url}
title={title}
className="iframe-block-frame"
loading="lazy"
onLoad={() => {
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
}
loadFallbackTimerRef.current = window.setTimeout(() => {
setFrameReady(true)
loadFallbackTimerRef.current = null
}, LOAD_FALLBACK_READY_MS)
}}
allow={allow}
allowFullScreen
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
/>
</div>
</div>
</NodeViewWrapper>
)
}
export const IframeBlockExtension = Node.create({
name: 'iframeBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-iframe')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'iframe-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(IframeBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```iframe\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})

View file

@ -1,145 +0,0 @@
import { z } from 'zod'
import { useMemo } from 'react'
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Sparkles } from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { PromptBlockSchema } from '@x/shared/dist/prompt-block.js'
import { Button } from '@/components/ui/button'
function truncate(text: string, maxLen: number): string {
const clean = text.replace(/\s+/g, ' ').trim()
if (clean.length <= maxLen) return clean
return clean.slice(0, maxLen).trimEnd() + '…'
}
function PromptBlockView({ node, extension }: {
node: { attrs: Record<string, unknown> }
extension: { options: { notePath?: string } }
}) {
const raw = node.attrs.data as string
const prompt = useMemo<z.infer<typeof PromptBlockSchema> | null>(() => {
try {
return PromptBlockSchema.parse(parseYaml(raw))
} catch { return null }
}, [raw])
const notePath = extension.options.notePath
const handleRun = (e: React.MouseEvent) => {
e.stopPropagation()
if (!prompt) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-prompt', {
detail: {
instruction: prompt.instruction,
label: prompt.label,
filePath: notePath,
},
}))
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleRun(e as unknown as React.MouseEvent)
}
}
if (!prompt) {
return (
<NodeViewWrapper data-type="prompt-block">
<div className="my-2 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
Invalid prompt block expected YAML with <code>label</code> and <code>instruction</code>.
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper data-type="prompt-block">
<div
role="button"
tabIndex={0}
onClick={handleRun}
onKeyDown={handleKey}
onMouseDown={(e) => e.stopPropagation()}
title={prompt.instruction}
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{prompt.label}</div>
<div className="truncate text-xs text-muted-foreground">{truncate(prompt.instruction, 80)}</div>
</div>
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
Run
</Button>
</div>
</NodeViewWrapper>
)
}
export const PromptBlockExtension = Node.create({
name: 'promptBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
notePath: undefined as string | undefined,
}
},
addAttributes() {
return {
data: {
default: '',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-prompt')) {
return { data: code.textContent || '' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prompt-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(PromptBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```prompt\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,178 @@
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 track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
try {
return TrackBlockSchema.parse(parseYaml(raw))
} catch { 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

@ -0,0 +1,90 @@
import { mergeAttributes, Node } from '@tiptap/react'
/**
* Track target markers two Tiptap atom nodes that represent the open and
* close HTML comment markers bracketing a track's output region on disk:
*
* <!--track-target:ID--> TrackTargetOpenExtension
* content in between regular Tiptap nodes (paragraphs, lists,
* custom blocks, whatever tiptap-markdown parses)
* <!--/track-target:ID--> TrackTargetCloseExtension
*
* The markers are *semantic boundaries*, not a UI container. Content between
* them is real, editable document content fully rendered by the existing
* extension set and freely editable by the user. The backend's updateContent()
* in fileops.ts still locates the region on disk by these comment markers.
*
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
* regex replace, converting each comment into a placeholder div that these
* extensions' parseHTML rules pick up. No content capture.
*
* Save path: both Tiptap's built-in markdown serializer
* (`addStorage().markdown.serialize`) AND the app's custom serializer
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
* back out they must stay in sync.
*/
type MarkerVariant = 'open' | 'close'
function buildMarkerExtension(variant: MarkerVariant) {
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
const commentFor = (id: string) =>
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
return Node.create({
name,
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
trackId: { default: '' },
}
},
parseHTML() {
return [
{
tag: `div[data-type="${htmlType}"]`,
getAttrs(el) {
if (!(el instanceof HTMLElement)) return false
return { trackId: el.getAttribute('data-track-id') ?? '' }
},
},
]
},
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': htmlType,
'data-track-id': (node.attrs.trackId as string) ?? '',
}),
]
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
node: { attrs: { trackId: string } },
) {
state.write(commentFor(node.attrs.trackId ?? ''))
state.closeBlock(node)
},
parse: {
// handled via preprocessTrackTargets → parseHTML
},
},
}
},
})
}
export const TrackTargetOpenExtension = buildMarkerExtension('open')
export const TrackTargetCloseExtension = buildMarkerExtension('close')

View file

@ -1,5 +1,6 @@
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
import { Node, mergeAttributes } from '@tiptap/react'
import { InputRule, inputRules } from '@tiptap/pm/inputrules'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
@ -25,12 +26,9 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
for (const match of matches) {
const matchIndex = match.index ?? 0
const matchText = match[0] ?? ''
const rawLink = match[1]?.trim() ?? ''
const { label } = splitWikiAlias(rawLink)
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
const rawPath = match[1]?.trim() ?? ''
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
if (matchIndex > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
@ -38,8 +36,7 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
if (isValidPath) {
const el = document.createElement('wiki-link')
el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
if (label) el.setAttribute('data-label', label)
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
fragment.appendChild(el)
} else {
fragment.appendChild(document.createTextNode(matchText))
@ -84,9 +81,6 @@ export const WikiLink = Node.create<WikiLinkOptions>({
path: {
default: '',
},
label: {
default: null,
},
}
},
@ -94,36 +88,30 @@ export const WikiLink = Node.create<WikiLinkOptions>({
return [
{
tag: 'wiki-link[data-path]',
getAttrs: (element: Element) => ({
getAttrs: (element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}),
},
{
tag: 'a[data-type="wiki-link"]',
getAttrs: (element: Element) => ({
getAttrs: (element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}),
},
]
},
renderHTML({ node, HTMLAttributes }) {
const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
const label = wikiLabel(node.attrs.path) || node.attrs.path
return [
'a',
mergeAttributes(
HTMLAttributes,
{
mergeAttributes(HTMLAttributes, {
'data-type': 'wiki-link',
'data-path': node.attrs.path,
'href': '#',
'class': 'wiki-link',
'aria-label': node.attrs.path,
},
node.attrs.label ? { 'data-label': node.attrs.label } : {}
),
}),
label,
]
},
@ -133,8 +121,7 @@ export const WikiLink = Node.create<WikiLinkOptions>({
markdown: {
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
const path = node.attrs.path ?? ''
const label = (node.attrs as { label?: string }).label
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
state.write(`[[${path}]]`)
},
parse: {
updateDOM(element: HTMLElement) {
@ -145,29 +132,23 @@ export const WikiLink = Node.create<WikiLinkOptions>({
}
},
addInputRules() {
addProseMirrorPlugins() {
const onCreate = this.options.onCreate
return [
new InputRule({
find: wikiLinkInputRegex,
handler: ({ state, range, match }) => {
const rawLink = match[1]?.trim()
const { label } = splitWikiAlias(rawLink ?? '')
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
if (
!normalizedPath
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
) return null
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
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
const finalPath = ensureMarkdownExtension(normalizedPath)
const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath }))
onCreate?.(finalPath)
},
return tr
}),
]
return [inputRules({ rules })]
},
})

View file

@ -1,72 +0,0 @@
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,124 +0,0 @@
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

@ -1,23 +1,23 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { LiveNoteAgentEvent } from '@x/shared/dist/live-note.js';
import { TrackEvent } from '@x/shared/dist/track-block.js';
export type LiveNoteAgentStatus = 'idle' | 'running' | 'done' | 'error';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
export interface LiveNoteAgentState {
status: LiveNoteAgentStatus;
export interface TrackState {
status: TrackRunStatus;
runId?: string;
summary?: string | null;
error?: string | null;
}
// Module-level store — shared across all hook consumers, subscribed once.
// We replace the Map on every mutation so useSyncExternalStore detects the change.
let store = new Map<string, LiveNoteAgentState>();
// Module-level store — shared across all hook consumers, subscribed once
// We replace the Map on every mutation so useSyncExternalStore detects the change
let store = new Map<string, TrackState>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, LiveNoteAgentState>) => void) {
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
@ -26,12 +26,12 @@ function updateStore(fn: (prev: Map<string, LiveNoteAgentState>) => void) {
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('live-note-agent:events', ((event: z.infer<typeof LiveNoteAgentEvent>) => {
const key = event.filePath;
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
const key = `${event.trackId}:${event.filePath}`;
if (event.type === 'live_note_agent_start') {
if (event.type === 'track_run_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'live_note_agent_complete') {
} else if (event.type === 'track_run_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 LiveNoteAgentEvent>) => void);
}) as (event: z.infer<typeof TrackEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
@ -52,21 +52,21 @@ function subscribe(onStoreChange: () => void): () => void {
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, LiveNoteAgentState> {
function getSnapshot(): Map<string, TrackState> {
return store;
}
/**
* Returns a Map of all live-note agent run states, keyed by `filePath`.
* Returns a Map of all track run states, keyed by "trackId:filePath".
*
* Usage in a panel:
* const status = useLiveNoteAgentStatus();
* const state = status.get(filePath) ?? { status: 'idle' };
* Usage in a track block component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const status = useLiveNoteAgentStatus();
* const anyRunning = [...status.values()].some(s => s.status === 'running');
* const trackStatus = useTrackStatus();
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
*/
export function useLiveNoteAgentStatus(): Map<string, LiveNoteAgentState> {
export function useTrackStatus(): Map<string, TrackState> {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -1,6 +1,5 @@
import { useEffect } from 'react'
import posthog from 'posthog-js'
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
/**
* Identifies the user in PostHog when signed into Rowboat,
@ -18,7 +17,7 @@ export function useAnalyticsIdentity() {
// Identify if Rowboat account is connected
const rowboat = config.rowboat
if (rowboat?.connected && rowboat?.userId) {
identifyUser(rowboat.userId)
posthog.identify(rowboat.userId)
}
// Set provider connection flags
@ -59,29 +58,15 @@ export function useAnalyticsIdentity() {
// Listen for OAuth connect/disconnect events to update identity
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider !== 'rowboat') {
// Other providers: just toggle the connection flag
if (event.success) {
if (!event.success) return
// If Rowboat provider connected, identify user
if (event.provider === 'rowboat' && event.userId) {
posthog.identify(event.userId)
posthog.people.set({ signed_in: true })
}
posthog.people.set({ [`${event.provider}_connected`]: true })
}
return
}
// Rowboat sign-in
if (event.success) {
if (event.userId) {
identifyUser(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')
resetAnalyticsIdentity()
})
return cleanup

View file

@ -1,5 +1,14 @@
import { useState, useEffect, useCallback } from 'react'
import type { BillingInfo } from '@x/shared/dist/billing.js'
interface BillingInfo {
userEmail: string | null
userId: string | null
subscriptionPlan: string | null
subscriptionStatus: string | null
trialExpiresAt: string | null
sanctionedCredits: number
availableCredits: number
}
export function useBilling(isRowboatConnected: boolean) {
const [billing, setBilling] = useState<BillingInfo | null>(null)

View file

@ -38,21 +38,16 @@ export function useConnectors(active: boolean) {
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// 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)
// Composio/Gmail state
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false)
const [useComposioForGoogleCalendar] = useState(false)
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
// Load available providers on mount
@ -72,7 +67,28 @@ export function useConnectors(active: boolean) {
loadProviders()
}, [])
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
// 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])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
@ -330,22 +346,13 @@ 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, providerStates])
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
@ -354,25 +361,6 @@ 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,
@ -497,6 +485,19 @@ 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()
}
})
@ -553,7 +554,6 @@ export function useConnectors(active: boolean) {
providerStatus,
hasProviderError,
handleConnect,
handleReconnect,
handleDisconnect,
startConnect,

View file

@ -1,42 +1,5 @@
import posthog from 'posthog-js'
let appVersion: string | undefined
let apiUrl: string | undefined
function appVersionProperties(): Record<string, string> {
return appVersion ? { app_version: appVersion } : {}
}
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
appVersion = props.appVersion?.trim() || undefined
apiUrl = props.apiUrl?.trim() || undefined
const eventProperties = appVersionProperties()
if (Object.keys(eventProperties).length > 0) {
posthog.register(eventProperties)
}
const personProperties = {
...(apiUrl ? { api_url: apiUrl } : {}),
...eventProperties,
}
if (Object.keys(personProperties).length > 0) {
posthog.people.set(personProperties)
}
}
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
posthog.identify(userId, {
...properties,
...appVersionProperties(),
})
}
export function resetAnalyticsIdentity() {
posthog.reset()
configureAnalyticsContext({ appVersion, apiUrl })
}
export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId })
}

View file

@ -1,26 +0,0 @@
export const BILLING_ERROR_PATTERNS = [
{
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
pattern: /not enough credits/i,
title: "You've run out of credits",
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
cta: 'Upgrade plan',
},
{
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.',
cta: 'Reactivate',
},
] as const
export type BillingErrorMatch = (typeof BILLING_ERROR_PATTERNS)[number]
export function matchBillingError(message: string): BillingErrorMatch | null {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
}

View file

@ -1,31 +0,0 @@
/**
* Matches a video-conference join URL for the providers we support (Zoom,
* Microsoft Teams, Google Meet). Captures the full URL up to the first
* whitespace, quote, or angle/round/square bracket.
*/
const MEETING_URL_RE =
/https?:\/\/(?:[a-z0-9-]+\.)*(?:zoom\.us|zoomgov\.com|teams\.microsoft\.com|teams\.live\.com|meet\.google\.com)\/[^\s"'<>)\]]+/i
function findMeetingUrl(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const match = MEETING_URL_RE.exec(value)
// Calendar descriptions are often HTML, so decode &amp; back to & in the URL.
return match ? match[0].replace(/&amp;/g, '&') : undefined
}
/**
* Extract a video conference link from raw Google Calendar event JSON.
* Checks conferenceData.entryPoints (video type), hangoutLink, a top-level
* conferenceLink, then falls back to scanning the location/description for a
* known meeting URL (Zoom, Microsoft Teams, Google Meet).
*/
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 findMeetingUrl(raw.location) ?? findMeetingUrl(raw.description)
}

View file

@ -1,8 +1,7 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
export interface MessageAttachment {
path: string
@ -25,12 +24,8 @@ export interface ToolCall {
name: string
input: ToolUIPart['input']
result?: ToolUIPart['output']
streamingOutput?: string
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
codeRunEvents?: CodeRunEvent[]
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
}
export interface ErrorMessage {
@ -50,7 +45,6 @@ export type ChatTabViewState = {
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
}
export type ChatViewportAnchorState = {
@ -65,7 +59,6 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(),
permissionResponses: new Map(),
autoPermissionDecisions: new Map(),
})
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
@ -485,19 +478,19 @@ export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardD
// Human-friendly display names for builtin tools
const TOOL_DISPLAY_NAMES: Record<string, string> = {
'file-readText': 'Reading file',
'file-writeText': 'Writing file',
'file-editText': 'Editing file',
'file-list': 'Reading directory',
'file-exists': 'Checking path',
'file-stat': 'Getting file info',
'file-glob': 'Finding files',
'file-grep': 'Searching files',
'file-mkdir': 'Creating directory',
'file-rename': 'Renaming',
'file-copy': 'Copying file',
'file-remove': 'Removing',
'file-getRoot': 'Getting file root',
'workspace-readFile': 'Reading file',
'workspace-writeFile': 'Writing file',
'workspace-edit': 'Editing file',
'workspace-readdir': 'Reading directory',
'workspace-exists': 'Checking path',
'workspace-stat': 'Getting file info',
'workspace-glob': 'Finding files',
'workspace-grep': 'Searching files',
'workspace-mkdir': 'Creating directory',
'workspace-rename': 'Renaming',
'workspace-copy': 'Copying file',
'workspace-remove': 'Removing',
'workspace-getRoot': 'Getting workspace root',
'loadSkill': 'Loading skill',
'parseFile': 'Parsing file',
'LLMParse': 'Extracting content',
@ -593,130 +586,6 @@ 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 (item.name === 'code_agent_run') return false // rich standalone block, never grouped
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(' · ')
}
// Past-tense action phrases for summarizing a finished tool group, e.g.
// "read 3 files, listed directory". Keyed by builtin tool name.
const TOOL_ACTION_VERBS: Record<string, { verb: string; one: string; many: string }> = {
'file-readText': { verb: 'read', one: 'file', many: 'files' },
'file-writeText': { verb: 'wrote', one: 'file', many: 'files' },
'file-editText': { verb: 'edited', one: 'file', many: 'files' },
'file-list': { verb: 'listed', one: 'directory', many: 'directories' },
'file-exists': { verb: 'checked', one: 'path', many: 'paths' },
'file-stat': { verb: 'inspected', one: 'file', many: 'files' },
'file-glob': { verb: 'searched for', one: 'file', many: 'files' },
'file-grep': { verb: 'searched', one: 'file', many: 'files' },
'file-mkdir': { verb: 'created', one: 'directory', many: 'directories' },
'file-rename': { verb: 'renamed', one: 'file', many: 'files' },
'file-copy': { verb: 'copied', one: 'file', many: 'files' },
'file-remove': { verb: 'removed', one: 'file', many: 'files' },
'file-getRoot': { verb: 'resolved', one: 'file root', many: 'file roots' },
'executeCommand': { verb: 'ran', one: 'command', many: 'commands' },
'executeMcpTool': { verb: 'ran', one: 'MCP tool', many: 'MCP tools' },
'listMcpServers': { verb: 'listed', one: 'MCP server', many: 'MCP servers' },
'listMcpTools': { verb: 'listed', one: 'MCP tool', many: 'MCP tools' },
'save-to-memory': { verb: 'saved', one: 'memory', many: 'memories' },
'loadSkill': { verb: 'loaded', one: 'skill', many: 'skills' },
'parseFile': { verb: 'parsed', one: 'file', many: 'files' },
}
// Summarize what a group of tools actually did, grouping identical actions
// and counting them: "read 3 files, listed directory". Unmapped tools fall
// back to their lowercased display name.
export const getToolActionsSummary = (tools: ToolCall[]): string => {
const order: string[] = []
const grouped = new Map<string, { phrase: typeof TOOL_ACTION_VERBS[string] | null; count: number; fallback: string }>()
for (const tool of tools) {
const phrase = TOOL_ACTION_VERBS[tool.name] ?? null
const key = phrase ? `${phrase.verb}|${phrase.one}` : tool.name
const existing = grouped.get(key)
if (existing) {
existing.count++
} else {
grouped.set(key, { phrase, count: 1, fallback: getToolDisplayName(tool) })
order.push(key)
}
}
const phrases = order.map((key) => {
const { phrase, count, fallback } = grouped.get(key)!
if (!phrase) return fallback.toLowerCase()
if (count > 1) return `${phrase.verb} ${count} ${phrase.many}`
const article = /^[aeiou]/i.test(phrase.one) ? 'an' : 'a'
return `${phrase.verb} ${article} ${phrase.one}`
})
// Show at most two operations; collapse the rest into "more...".
const MAX_ACTIONS = 2
if (phrases.length > MAX_ACTIONS) {
return `${phrases.slice(0, MAX_ACTIONS).join(', ')}, more...`
}
return phrases.join(', ')
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()

View file

@ -1,57 +0,0 @@
/**
* 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' | 'docx'
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',
docx: 'docx',
}
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,19 +133,9 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
}
/**
* 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.
* 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.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {}
@ -153,12 +143,10 @@ 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
}
@ -167,61 +155,39 @@ 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
}
if (!currentKey) continue
// List item under current key.
const itemMatch = line.match(/^\s+-\s+(.*)$/)
// List item under current key
if (currentKey) {
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
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim())
}
}
}
// Indented continuation of a nested object — keep dropping its parent.
if (pendingNested && /^\s/.test(line)) continue
}
return result
}
/**
* 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.
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
* Returns null if no non-empty fields remain.
*/
export function buildFrontmatter(
fields: Record<string, string | string[]>,
preserveRaw: string | null = null,
): string | null {
export function buildFrontmatter(fields: Record<string, string | string[]>): 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}:`)
@ -234,55 +200,8 @@ export function buildFrontmatter(
lines.push(`${key}: ${trimmed}`)
}
}
// 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')
if (lines.length === 0) return null
return `---\n${lines.join('\n')}\n---`
}
/** Map known tag values → category for legacy flat-list frontmatter. */

View file

@ -1,25 +0,0 @@
/**
* 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

@ -1,138 +0,0 @@
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

@ -1,319 +0,0 @@
/**
* 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

@ -3,50 +3,24 @@ const KNOWLEDGE_PREFIX = 'knowledge/'
export const stripKnowledgePrefix = (path: string) =>
path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path
export const splitWikiAlias = (input: string) => {
const separatorIndex = input.indexOf('|')
if (separatorIndex === -1) return { target: input, label: undefined }
const target = input.slice(0, separatorIndex)
const label = input.slice(separatorIndex + 1).trim()
return { target, label: label || undefined }
}
export const splitWikiFragment = (path: string) => {
const hashIndex = path.indexOf('#')
if (hashIndex === -1) return { path: path, heading: undefined }
const basePath = path.slice(0, hashIndex)
const heading = path.slice(hashIndex + 1).trim()
return { path: basePath, heading: heading || undefined }
}
export const normalizeWikiPath = (input: string) => {
const { target } = splitWikiAlias(input)
const trimmed = target.trim().replace(/^\/+/, '').replace(/^\.\//, '')
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '')
return stripKnowledgePrefix(trimmed)
}
export const ensureMarkdownExtension = (path: string) => {
const { path: basePath, heading } = splitWikiFragment(path)
if (!basePath) return heading ? `#${heading}` : path
const filePath = basePath.toLowerCase().endsWith('.md') ? basePath : `${basePath}.md`
return heading ? `${filePath}#${heading}` : filePath
if (path.toLowerCase().endsWith('.md')) return path
return `${path}.md`
}
export const toKnowledgePath = (wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath)
const { path: basePath } = splitWikiFragment(normalized)
if (!basePath || basePath.includes('..') || basePath.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(basePath)}`
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}`
}
export const wikiLabel = (wikiPath: string) => {
const { label } = splitWikiAlias(wikiPath)
if (label) return label
const normalized = normalizeWikiPath(wikiPath)
const { path: basePath, heading } = splitWikiFragment(normalized)
if (!basePath && heading) return heading
const name = (basePath || normalized).split('/').pop() || normalized
const name = normalized.split('/').pop() || normalized
return name.replace(/\.md$/i, '')
}

View file

@ -3,48 +3,14 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { PostHogProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js'
import { ThemeProvider } from '@/contexts/theme-context'
import { configureAnalyticsContext } from './lib/analytics'
// 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
let appVersion: string | undefined
try {
const result = await window.ipc.invoke('analytics:bootstrap', null)
installationId = result.installationId
apiUrl = result.apiUrl
appVersion = result.appVersion
} catch (err) {
console.error('[Analytics] Failed to bootstrap from main:', err)
}
configureAnalyticsContext({ apiUrl, appVersion })
const options = {
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-11-30' as const,
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
before_send: (event: CaptureResult | null) => {
if (!event) return event
if (appVersion) {
event.properties = {
...event.properties,
app_version: appVersion,
}
}
return event
},
loaded: () => {
configureAnalyticsContext({ apiUrl, appVersion })
},
}
defaults: '2025-11-30',
} as const
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
<ThemeProvider defaultTheme="system">
@ -52,9 +18,4 @@ async function bootstrap() {
</ThemeProvider>
</PostHogProvider>
</StrictMode>,
)
// The loaded callback applies api_url/app_version once PostHog has initialized.
}
bootstrap()
)

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more