Compare commits

..

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

253 changed files with 5288 additions and 39043 deletions

View file

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

1
.gitignore vendored
View file

@ -3,4 +3,3 @@
.vscode/ .vscode/
data/ data/
.venv/ .venv/
.claude/

View file

@ -102,15 +102,6 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca
| Workspace config | `apps/x/pnpm-workspace.yaml` | | Workspace config | `apps/x/pnpm-workspace.yaml` |
| Root scripts | `apps/x/package.json` | | Root scripts | `apps/x/package.json` |
## Feature Deep-Dives
Long-form docs for specific features. Read the relevant file before making changes in that area — it has the full product flow, technical flows, and (where applicable) a catalog of the LLM prompts involved with exact file:line pointers.
| Feature | Doc |
|---------|-----|
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
## Common Tasks ## Common Tasks
### LLM configuration (single provider) ### LLM configuration (single provider)

View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "renderer-dev",
"runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite",
"runtimeArgs": ["--port", "5173"],
"port": 5173
}
]
}

1
apps/x/.gitignore vendored
View file

@ -1,2 +1 @@
node_modules/ 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` |

View file

@ -10,13 +10,11 @@
*/ */
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // 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, // 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. // 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 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({ await esbuild.build({
entryPoints: ['./dist/main.js'], entryPoints: ['./dist/main.js'],
@ -33,12 +31,6 @@ await esbuild.build({
// Replace import.meta.url directly with our polyfill variable // Replace import.meta.url directly with our polyfill variable
define: { define: {
'import.meta.url': '__import_meta_url', '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 icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app', appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity', appCategoryType: 'public.app-category.productivity',
protocols: [
{ name: 'Rowboat', schemes: ['rowboat'] },
],
extendInfo: { extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', 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', description: 'AI coworker with memory',
name: `Rowboat-win32-${arch}`, name: `Rowboat-win32-${arch}`,
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
setupIcon: path.join(__dirname, 'icons/icon.ico'),
}) })
}, },
{ {
@ -67,9 +63,7 @@ module.exports = {
bin: "rowboat", bin: "rowboat",
description: 'AI coworker with memory', description: 'AI coworker with memory',
maintainer: 'rowboatlabs', maintainer: 'rowboatlabs',
homepage: 'https://rowboatlabs.com', homepage: 'https://rowboatlabs.com'
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
} }
}) })
}, },
@ -80,9 +74,7 @@ module.exports = {
name: `Rowboat-linux`, name: `Rowboat-linux`,
bin: "rowboat", bin: "rowboat",
description: 'AI coworker with memory', description: 'AI coworker with memory',
homepage: 'https://rowboatlabs.com', homepage: 'https://rowboatlabs.com'
icon: path.join(__dirname, 'icons/icon.png'),
mimeType: ['x-scheme-handler/rowboat'],
} }
} }
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View file

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

View file

@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
import { URL } from 'url'; import { URL } from 'url';
const OAUTH_CALLBACK_PATH = '/oauth/callback'; const OAUTH_CALLBACK_PATH = '/oauth/callback';
export const DEFAULT_PORT = 8080; const DEFAULT_PORT = 8080;
export const PORT_RANGE_SIZE = 10;
/** Escape HTML special characters to prevent XSS */ /** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string { function escapeHtml(str: string): string {
@ -20,8 +19,13 @@ export interface AuthServerResult {
port: number; 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> onCallback: (callbackUrl: URL) => void | Promise<void>
): Promise<AuthServerResult> { ): Promise<AuthServerResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -33,7 +37,7 @@ function tryBindPort(
} }
const url = new URL(req.url, `http://localhost:${port}`); const url = new URL(req.url, `http://localhost:${port}`);
if (url.pathname === OAUTH_CALLBACK_PATH) { if (url.pathname === OAUTH_CALLBACK_PATH) {
const error = url.searchParams.get('error'); const error = url.searchParams.get('error');
@ -92,10 +96,8 @@ function tryBindPort(
}); });
server.on('error', (err: NodeJS.ErrnoException) => { server.on('error', (err: NodeJS.ErrnoException) => {
server.close(); if (err.code === 'EADDRINUSE') {
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { reject(new Error(`Port ${port} is already in use`));
// Signal caller to try next port
reject(Object.assign(new Error(err.code), { code: err.code }));
} else { } else {
reject(err); 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,265 +0,0 @@
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 { 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,
page?: BrowserControlResult['page'],
): BrowserControlResult {
return {
success: true,
action,
message,
browser: browserViewManager.getState(),
...(page ? { page } : {}),
};
}
function buildErrorResult(action: BrowserControlAction, error: string): BrowserControlResult {
return {
success: false,
action,
error,
browser: browserViewManager.getState(),
};
}
export class ElectronBrowserControlService implements IBrowserControlService {
async execute(
input: BrowserControlInput,
ctx?: { signal?: AbortSignal },
): Promise<BrowserControlResult> {
const signal = ctx?.signal;
try {
switch (input.action) {
case 'open': {
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('open', 'Opened a browser session.', page);
}
case 'get-state':
return buildSuccessResult('get-state', 'Read the current browser state.');
case 'new-tab': {
const target = input.target ? normalizeNavigationTarget(input.target) : undefined;
const result = await browserViewManager.newTab(target);
if (!result.ok) {
return buildErrorResult('new-tab', result.error ?? 'Failed to open a new tab.');
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
const suggestedSkills = await getSuggestedSkills(page?.url);
const success = buildSuccessResult(
'new-tab',
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
page,
);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'switch-tab': {
const tabId = input.tabId;
if (!tabId) {
return buildErrorResult('switch-tab', 'tabId is required for switch-tab.');
}
const result = browserViewManager.switchTab(tabId);
if (!result.ok) {
return buildErrorResult('switch-tab', `No browser tab exists with id ${tabId}.`);
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('switch-tab', `Switched to tab ${tabId}.`, page);
}
case 'close-tab': {
const tabId = input.tabId;
if (!tabId) {
return buildErrorResult('close-tab', 'tabId is required for close-tab.');
}
const result = browserViewManager.closeTab(tabId);
if (!result.ok) {
return buildErrorResult('close-tab', `Could not close tab ${tabId}.`);
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('close-tab', `Closed tab ${tabId}.`, page);
}
case 'navigate': {
const rawTarget = input.target;
if (!rawTarget) {
return buildErrorResult('navigate', 'target is required for navigate.');
}
const target = normalizeNavigationTarget(rawTarget);
const result = await browserViewManager.navigate(target);
if (!result.ok) {
return buildErrorResult('navigate', result.error ?? `Failed to navigate to ${target}.`);
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
const suggestedSkills = await getSuggestedSkills(page?.url);
const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'back': {
const result = browserViewManager.back();
if (!result.ok) {
return buildErrorResult('back', 'The active tab cannot go back.');
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('back', 'Went back in the active tab.', page);
}
case 'forward': {
const result = browserViewManager.forward();
if (!result.ok) {
return buildErrorResult('forward', 'The active tab cannot go forward.');
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('forward', 'Went forward in the active tab.', page);
}
case 'reload': {
browserViewManager.reload();
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('reload', 'Reloaded the active tab.', page);
}
case 'read-page': {
const result = await browserViewManager.readPage(
{
maxElements: input.maxElements,
maxTextLength: input.maxTextLength,
},
signal,
);
if (!result.ok || !result.page) {
return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.');
}
const suggestedSkills = await getSuggestedSkills(result.page.url);
const success = buildSuccessResult('read-page', 'Read the current page.', result.page);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'click': {
const result = await browserViewManager.click(
{
index: input.index,
selector: input.selector,
snapshotId: input.snapshotId,
},
signal,
);
if (!result.ok) {
return buildErrorResult('click', result.error ?? 'Failed to click the requested element.');
}
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult(
'click',
result.description ? `Clicked ${result.description}.` : 'Clicked the requested element.',
page,
);
}
case 'type': {
const text = input.text;
if (text === undefined) {
return buildErrorResult('type', 'text is required for type.');
}
const result = await browserViewManager.type(
{
index: input.index,
selector: input.selector,
snapshotId: input.snapshotId,
},
text,
signal,
);
if (!result.ok) {
return buildErrorResult('type', result.error ?? 'Failed to type into the requested element.');
}
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult(
'type',
result.description ? `Typed into ${result.description}.` : 'Typed into the requested element.',
page,
);
}
case 'press': {
const key = input.key;
if (!key) {
return buildErrorResult('press', 'key is required for press.');
}
const result = await browserViewManager.press(
key,
{
index: input.index,
selector: input.selector,
snapshotId: input.snapshotId,
},
signal,
);
if (!result.ok) {
return buildErrorResult('press', result.error ?? `Failed to press ${key}.`);
}
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult(
'press',
result.description ? `Pressed ${result.description}.` : `Pressed ${key}.`,
page,
);
}
case 'scroll': {
const result = await browserViewManager.scroll(
input.direction ?? 'down',
input.amount ?? 700,
signal,
);
if (!result.ok) {
return buildErrorResult('scroll', result.error ?? 'Failed to scroll the page.');
}
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('scroll', `Scrolled ${input.direction ?? 'down'}.`, page);
}
case 'wait': {
const duration = input.ms ?? 1000;
await browserViewManager.wait(duration, signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('wait', `Waited ${duration}ms for the page to settle.`, page);
}
}
} catch (error) {
return buildErrorResult(
input.action,
error instanceof Error ? error.message : 'Browser control failed unexpectedly.',
);
}
}
}

View file

@ -1,81 +0,0 @@
import { BrowserWindow } from 'electron';
import { ipc } from '@x/shared';
import { browserViewManager, type BrowserState } from './view.js';
type IPCChannels = ipc.IPCChannels;
type InvokeHandler<K extends keyof IPCChannels> = (
event: Electron.IpcMainInvokeEvent,
args: IPCChannels[K]['req'],
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
type BrowserHandlers = {
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
'browser:newTab': InvokeHandler<'browser:newTab'>;
'browser:switchTab': InvokeHandler<'browser:switchTab'>;
'browser:closeTab': InvokeHandler<'browser:closeTab'>;
'browser:navigate': InvokeHandler<'browser:navigate'>;
'browser:back': InvokeHandler<'browser:back'>;
'browser:forward': InvokeHandler<'browser:forward'>;
'browser:reload': InvokeHandler<'browser:reload'>;
'browser:getState': InvokeHandler<'browser:getState'>;
};
/**
* Browser-specific IPC handlers, exported as a plain object so they can be
* spread into the main `registerIpcHandlers({...})` call in ipc.ts. This
* mirrors the convention of keeping feature handlers flat and namespaced by
* channel prefix (`browser:*`).
*/
export const browserIpcHandlers: BrowserHandlers = {
'browser:setBounds': async (_event, args) => {
browserViewManager.setBounds(args);
return { ok: true };
},
'browser:setVisible': async (_event, args) => {
browserViewManager.setVisible(args.visible);
return { ok: true };
},
'browser:newTab': async (_event, args) => {
return browserViewManager.newTab(args.url);
},
'browser:switchTab': async (_event, args) => {
return browserViewManager.switchTab(args.tabId);
},
'browser:closeTab': async (_event, args) => {
return browserViewManager.closeTab(args.tabId);
},
'browser:navigate': async (_event, args) => {
return browserViewManager.navigate(args.url);
},
'browser:back': async () => {
return browserViewManager.back();
},
'browser:forward': async () => {
return browserViewManager.forward();
},
'browser:reload': async () => {
browserViewManager.reload();
return { ok: true };
},
'browser:getState': async () => {
return browserViewManager.getState();
},
};
/**
* Wire the BrowserViewManager's state-updated event to all renderer windows
* as a `browser:didUpdateState` push. Must be called once after the main
* window is created so the manager has a window to attach to.
*/
export function setupBrowserEventForwarding(): void {
browserViewManager.on('state-updated', (state: BrowserState) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('browser:didUpdateState', state);
}
}
});
}

View file

@ -1,41 +0,0 @@
const SEARCH_ENGINE_BASE_URL = 'https://www.google.com/search?q=';
const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const IPV4_HOST_RE = /^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/.*)?$/;
const LOCALHOST_RE = /^localhost(?::\d+)?(?:\/.*)?$/i;
const DOMAIN_LIKE_RE = /^[\w.-]+\.[a-z]{2,}(?::\d+)?(?:\/.*)?$/i;
export function normalizeNavigationTarget(target: string): string {
const trimmed = target.trim();
if (!trimmed) {
throw new Error('Navigation target cannot be empty.');
}
const lower = trimmed.toLowerCase();
if (
lower.startsWith('javascript:')
|| lower.startsWith('file://')
|| lower.startsWith('chrome://')
|| lower.startsWith('chrome-extension://')
) {
throw new Error('That URL scheme is not allowed in the embedded browser.');
}
if (HAS_SCHEME_RE.test(trimmed)) {
return trimmed;
}
const looksLikeHost =
LOCALHOST_RE.test(trimmed)
|| DOMAIN_LIKE_RE.test(trimmed)
|| IPV4_HOST_RE.test(trimmed);
if (looksLikeHost && !/\s/.test(trimmed)) {
const scheme = LOCALHOST_RE.test(trimmed) || IPV4_HOST_RE.test(trimmed)
? 'http://'
: 'https://';
return `${scheme}${trimmed}`;
}
return `${SEARCH_ENGINE_BASE_URL}${encodeURIComponent(trimmed)}`;
}

View file

@ -1,546 +0,0 @@
import type { BrowserPageElement } from '@x/shared/dist/browser-control.js';
const INTERACTABLE_SELECTORS = [
'a[href]',
'button',
'input',
'textarea',
'select',
'summary',
'[role="button"]',
'[role="link"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="option"]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const CLICKABLE_TARGET_SELECTORS = [
'a[href]',
'button',
'summary',
'label',
'input',
'textarea',
'select',
'[role="button"]',
'[role="link"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="option"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[aria-pressed]',
'[aria-expanded]',
'[aria-checked]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const DOM_HELPERS_SOURCE = String.raw`
const truncateText = (value, max) => {
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= max) return normalized;
const safeMax = Math.max(0, max - 3);
return normalized.slice(0, safeMax).trim() + '...';
};
const cssEscapeValue = (value) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\' + char);
};
const isVisibleElement = (element) => {
if (!(element instanceof Element)) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
if (element.getAttribute('aria-hidden') === 'true') return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const isDisabledElement = (element) => {
if (!(element instanceof Element)) return true;
if (element.getAttribute('aria-disabled') === 'true') return true;
return 'disabled' in element && Boolean(element.disabled);
};
const isUselessClickTarget = (element) => (
element === document.body
|| element === document.documentElement
);
const getElementRole = (element) => {
const explicitRole = element.getAttribute('role');
if (explicitRole) return explicitRole;
if (element instanceof HTMLAnchorElement) return 'link';
if (element instanceof HTMLButtonElement) return 'button';
if (element instanceof HTMLInputElement) return element.type === 'checkbox' ? 'checkbox' : 'input';
if (element instanceof HTMLTextAreaElement) return 'textbox';
if (element instanceof HTMLSelectElement) return 'combobox';
if (element instanceof HTMLElement && element.isContentEditable) return 'textbox';
return null;
};
const getElementType = (element) => {
if (element instanceof HTMLInputElement) return element.type || 'text';
if (element instanceof HTMLTextAreaElement) return 'textarea';
if (element instanceof HTMLSelectElement) return 'select';
if (element instanceof HTMLButtonElement) return 'button';
if (element instanceof HTMLElement && element.isContentEditable) return 'contenteditable';
return null;
};
const getElementLabel = (element) => {
const ariaLabel = truncateText(element.getAttribute('aria-label') ?? '', 120);
if (ariaLabel) return ariaLabel;
if ('labels' in element && element.labels && element.labels.length > 0) {
const labelText = truncateText(
Array.from(element.labels).map((label) => label.innerText || label.textContent || '').join(' '),
120,
);
if (labelText) return labelText;
}
if (element.id) {
const label = document.querySelector('label[for="' + cssEscapeValue(element.id) + '"]');
const labelText = truncateText(label?.textContent ?? '', 120);
if (labelText) return labelText;
}
const placeholder = truncateText(element.getAttribute('placeholder') ?? '', 120);
if (placeholder) return placeholder;
const text = truncateText(
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
? element.value
: element.textContent ?? '',
120,
);
return text || null;
};
const describeElement = (element) => {
const role = getElementRole(element) || element.tagName.toLowerCase();
const label = getElementLabel(element);
return label ? role + ' "' + label + '"' : role;
};
const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max);
const getAssociatedControl = (element) => {
if (!(element instanceof Element)) return null;
if (element instanceof HTMLLabelElement) return element.control;
const parentLabel = element.closest('label');
return parentLabel instanceof HTMLLabelElement ? parentLabel.control : null;
};
const resolveClickTarget = (element) => {
if (!(element instanceof Element)) return null;
const clickableAncestor = element.closest(${JSON.stringify(CLICKABLE_TARGET_SELECTORS)});
const labelAncestor = element.closest('label');
const associatedControl = getAssociatedControl(element);
const candidates = [clickableAncestor, labelAncestor, associatedControl, element];
for (const candidate of candidates) {
if (!(candidate instanceof Element)) continue;
if (isUselessClickTarget(candidate)) continue;
if (!isVisibleElement(candidate)) continue;
if (isDisabledElement(candidate)) continue;
return candidate;
}
for (const candidate of candidates) {
if (candidate instanceof Element) return candidate;
}
return null;
};
const getVerificationTargetState = (element) => {
if (!(element instanceof Element)) return null;
const text = truncateText(element.innerText || element.textContent || '', 200);
const activeElement = document.activeElement;
const isActive =
activeElement instanceof Element
? activeElement === element || element.contains(activeElement)
: false;
return {
selector: buildUniqueSelector(element),
descriptor: describeElement(element),
text: text || null,
checked:
element instanceof HTMLInputElement && (element.type === 'checkbox' || element.type === 'radio')
? element.checked
: null,
value:
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
? truncateText(element.value ?? '', 200)
: element instanceof HTMLSelectElement
? truncateText(element.value ?? '', 200)
: element instanceof HTMLElement && element.isContentEditable
? truncateText(element.innerText || element.textContent || '', 200)
: null,
selectedIndex: element instanceof HTMLSelectElement ? element.selectedIndex : null,
open:
'open' in element && typeof element.open === 'boolean'
? element.open
: null,
disabled: isDisabledElement(element),
active: isActive,
ariaChecked: element.getAttribute('aria-checked'),
ariaPressed: element.getAttribute('aria-pressed'),
ariaExpanded: element.getAttribute('aria-expanded'),
};
};
const getPageVerificationState = () => {
const activeElement = document.activeElement instanceof Element ? document.activeElement : null;
return {
url: window.location.href,
title: document.title || '',
textSample: truncateText(document.body?.innerText || document.body?.textContent || '', 2000),
activeSelector: activeElement ? buildUniqueSelector(activeElement) : null,
};
};
const buildUniqueSelector = (element) => {
if (!(element instanceof Element)) return null;
if (element.id) {
const idSelector = '#' + cssEscapeValue(element.id);
try {
if (document.querySelectorAll(idSelector).length === 1) return idSelector;
} catch {}
}
const segments = [];
let current = element;
while (current && current instanceof Element && current !== document.documentElement) {
const tag = current.tagName.toLowerCase();
if (!tag) break;
let segment = tag;
const name = current.getAttribute('name');
if (name) {
const nameSelector = tag + '[name="' + cssEscapeValue(name) + '"]';
try {
if (document.querySelectorAll(nameSelector).length === 1) {
segments.unshift(nameSelector);
return segments.join(' > ');
}
} catch {}
}
const parent = current.parentElement;
if (parent) {
const sameTagSiblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
const position = sameTagSiblings.indexOf(current) + 1;
segment += ':nth-of-type(' + position + ')';
}
segments.unshift(segment);
const selector = segments.join(' > ');
try {
if (document.querySelectorAll(selector).length === 1) return selector;
} catch {}
current = current.parentElement;
}
return segments.length > 0 ? segments.join(' > ') : null;
};
`;
type RawBrowserPageElement = BrowserPageElement & {
selector: string;
};
export type RawBrowserPageSnapshot = {
url: string;
title: string;
loading: boolean;
text: string;
elements: RawBrowserPageElement[];
};
export type ElementTarget = {
index?: number;
selector?: string;
snapshotId?: string;
};
export function buildReadPageScript(maxElements: number, maxTextLength: number): string {
return `(() => {
${DOM_HELPERS_SOURCE}
const candidates = Array.from(document.querySelectorAll(${JSON.stringify(INTERACTABLE_SELECTORS)}));
const elements = [];
const seenSelectors = new Set();
for (const candidate of candidates) {
if (!(candidate instanceof Element)) continue;
if (!isVisibleElement(candidate)) continue;
const selector = buildUniqueSelector(candidate);
if (!selector || seenSelectors.has(selector)) continue;
seenSelectors.add(selector);
elements.push({
index: elements.length + 1,
selector,
tagName: candidate.tagName.toLowerCase(),
role: getElementRole(candidate),
type: getElementType(candidate),
label: getElementLabel(candidate),
text: truncateText(candidate.innerText || candidate.textContent || '', 120) || null,
placeholder: truncateText(candidate.getAttribute('placeholder') ?? '', 120) || null,
href: candidate instanceof HTMLAnchorElement ? candidate.href : candidate.getAttribute('href'),
disabled: isDisabledElement(candidate),
});
if (elements.length >= ${JSON.stringify(maxElements)}) break;
}
return {
url: window.location.href,
title: document.title || '',
loading: document.readyState !== 'complete',
text: truncateText(document.body?.innerText || document.body?.textContent || '', ${JSON.stringify(maxTextLength)}),
elements,
};
})()`;
}
export function buildClickScript(selector: string): string {
return `(() => {
${DOM_HELPERS_SOURCE}
const requestedSelector = ${JSON.stringify(selector)};
if (/^(body|html)$/i.test(requestedSelector.trim())) {
return {
ok: false,
error: 'Refusing to click the page body. Read the page again and target a specific element.',
};
}
const element = document.querySelector(requestedSelector);
if (!(element instanceof Element)) {
return { ok: false, error: 'Element not found.' };
}
if (isUselessClickTarget(element)) {
return {
ok: false,
error: 'Refusing to click the page body. Read the page again and target a specific element.',
};
}
const target = resolveClickTarget(element);
if (!(target instanceof Element)) {
return { ok: false, error: 'Could not resolve a clickable target.' };
}
if (isUselessClickTarget(target)) {
return {
ok: false,
error: 'Resolved click target was too generic. Read the page again and choose a specific control.',
};
}
if (!isVisibleElement(target)) {
return { ok: false, error: 'Resolved click target is not visible.' };
}
if (isDisabledElement(target)) {
return { ok: false, error: 'Resolved click target is disabled.' };
}
const before = {
page: getPageVerificationState(),
target: getVerificationTargetState(target),
};
if (target instanceof HTMLElement) {
target.scrollIntoView({ block: 'center', inline: 'center' });
target.focus({ preventScroll: true });
}
const rect = target.getBoundingClientRect();
const clientX = clampNumber(rect.left + (rect.width / 2), 1, Math.max(1, window.innerWidth - 1));
const clientY = clampNumber(rect.top + (rect.height / 2), 1, Math.max(1, window.innerHeight - 1));
const topElement = document.elementFromPoint(clientX, clientY);
const eventTarget =
topElement instanceof Element && (topElement === target || topElement.contains(target) || target.contains(topElement))
? topElement
: target;
if (eventTarget instanceof HTMLElement) {
eventTarget.focus({ preventScroll: true });
}
return {
ok: true,
description: describeElement(target),
clickPoint: {
x: Math.round(clientX),
y: Math.round(clientY),
},
verification: {
before,
targetSelector: buildUniqueSelector(target) || requestedSelector,
},
};
})()`;
}
export function buildVerifyClickScript(targetSelector: string | null, before: unknown): string {
return `(() => {
${DOM_HELPERS_SOURCE}
const beforeState = ${JSON.stringify(before)};
const selector = ${JSON.stringify(targetSelector)};
const afterPage = getPageVerificationState();
const afterTarget = selector ? getVerificationTargetState(document.querySelector(selector)) : null;
const beforeTarget = beforeState?.target ?? null;
const reasons = [];
if (beforeState?.page?.url !== afterPage.url) reasons.push('url changed');
if (beforeState?.page?.title !== afterPage.title) reasons.push('title changed');
if (beforeState?.page?.textSample !== afterPage.textSample) reasons.push('page text changed');
if (beforeState?.page?.activeSelector !== afterPage.activeSelector) reasons.push('focus changed');
if (beforeTarget && !afterTarget) {
reasons.push('clicked element disappeared');
}
if (beforeTarget && afterTarget) {
if (beforeTarget.checked !== afterTarget.checked) reasons.push('checked state changed');
if (beforeTarget.value !== afterTarget.value) reasons.push('value changed');
if (beforeTarget.selectedIndex !== afterTarget.selectedIndex) reasons.push('selection changed');
if (beforeTarget.open !== afterTarget.open) reasons.push('open state changed');
if (beforeTarget.disabled !== afterTarget.disabled) reasons.push('disabled state changed');
if (beforeTarget.active !== afterTarget.active) reasons.push('target focus changed');
if (beforeTarget.ariaChecked !== afterTarget.ariaChecked) reasons.push('aria-checked changed');
if (beforeTarget.ariaPressed !== afterTarget.ariaPressed) reasons.push('aria-pressed changed');
if (beforeTarget.ariaExpanded !== afterTarget.ariaExpanded) reasons.push('aria-expanded changed');
if (beforeTarget.text !== afterTarget.text) reasons.push('target text changed');
}
return {
changed: reasons.length > 0,
reasons,
};
})()`;
}
export function buildTypeScript(selector: string, text: string): string {
return `(() => {
${DOM_HELPERS_SOURCE}
const element = document.querySelector(${JSON.stringify(selector)});
if (!(element instanceof Element)) {
return { ok: false, error: 'Element not found.' };
}
if (!isVisibleElement(element)) {
return { ok: false, error: 'Element is not visible.' };
}
if (isDisabledElement(element)) {
return { ok: false, error: 'Element is disabled.' };
}
const nextValue = ${JSON.stringify(text)};
const setNativeValue = (target, value) => {
const prototype = Object.getPrototypeOf(target);
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
if (descriptor && typeof descriptor.set === 'function') {
descriptor.set.call(target, value);
} else {
target.value = value;
}
};
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
if (element.readOnly) {
return { ok: false, error: 'Element is read-only.' };
}
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus({ preventScroll: true });
setNativeValue(element, nextValue);
element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true, description: describeElement(element) };
}
if (element instanceof HTMLElement && element.isContentEditable) {
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus({ preventScroll: true });
element.textContent = nextValue;
element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' }));
return { ok: true, description: describeElement(element) };
}
return { ok: false, error: 'Element does not accept text input.' };
})()`;
}
export function buildFocusScript(selector: string): string {
return `(() => {
${DOM_HELPERS_SOURCE}
const element = document.querySelector(${JSON.stringify(selector)});
if (!(element instanceof Element)) {
return { ok: false, error: 'Element not found.' };
}
if (!isVisibleElement(element)) {
return { ok: false, error: 'Element is not visible.' };
}
if (element instanceof HTMLElement) {
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus({ preventScroll: true });
}
return { ok: true, description: describeElement(element) };
})()`;
}
export function buildScrollScript(offset: number): string {
return `(() => {
window.scrollBy({ top: ${JSON.stringify(offset)}, left: 0, behavior: 'auto' });
return { ok: true };
})()`;
}
export function normalizeKeyCode(key: string): string {
const trimmed = key.trim();
if (!trimmed) return 'Enter';
const aliases: Record<string, string> = {
esc: 'Escape',
escape: 'Escape',
return: 'Enter',
enter: 'Enter',
tab: 'Tab',
space: 'Space',
' ': 'Space',
left: 'ArrowLeft',
right: 'ArrowRight',
up: 'ArrowUp',
down: 'ArrowDown',
arrowleft: 'ArrowLeft',
arrowright: 'ArrowRight',
arrowup: 'ArrowUp',
arrowdown: 'ArrowDown',
backspace: 'Backspace',
delete: 'Delete',
};
const alias = aliases[trimmed.toLowerCase()];
if (alias) return alias;
if (trimmed.length === 1) return trimmed.toUpperCase();
return trimmed[0].toUpperCase() + trimmed.slice(1);
}

View file

@ -1,840 +0,0 @@
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
import type {
BrowserPageElement,
BrowserPageSnapshot,
BrowserState,
BrowserTabState,
} from '@x/shared/dist/browser-control.js';
import { normalizeNavigationTarget } from './navigation.js';
import {
buildClickScript,
buildFocusScript,
buildReadPageScript,
buildScrollScript,
buildTypeScript,
buildVerifyClickScript,
normalizeKeyCode,
type ElementTarget,
type RawBrowserPageSnapshot,
} from './page-scripts.js';
export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
/**
* Embedded browser pane implementation.
*
* Each browser tab owns its own WebContentsView. Only the active tab's view is
* attached to the main window at a time, but inactive tabs keep their own page
* history and loaded state in memory so switching tabs feels immediate.
*
* All tabs share one persistent session partition so cookies/localStorage/
* form-fill state survive app restarts, and the browser surface spoofs a
* standard Chrome UA so sites like Google (OAuth) don't reject it.
*/
export const BROWSER_PARTITION = 'persist:rowboat-browser';
// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
// that sniff the UA looking for "real browser" shapes.
const SPOOF_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
const HOME_URL = 'https://www.google.com';
const NAVIGATION_TIMEOUT_MS = 10000;
const POST_ACTION_IDLE_MS = 400;
const POST_ACTION_MAX_ELEMENTS = 25;
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
const DEFAULT_READ_MAX_ELEMENTS = 50;
const DEFAULT_READ_MAX_TEXT_LENGTH = 8000;
export interface BrowserBounds {
x: number;
y: number;
width: number;
height: number;
}
type BrowserTab = {
id: string;
view: WebContentsView;
domReadyAt: number | null;
loadError: string | null;
};
type CachedSnapshot = {
snapshotId: string;
elements: Array<{ index: number; selector: string }>;
};
const EMPTY_STATE: BrowserState = {
activeTabId: null,
tabs: [],
};
function abortIfNeeded(signal?: AbortSignal): void {
if (!signal?.aborted) return;
throw signal.reason instanceof Error ? signal.reason : new Error('Browser action aborted');
}
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) return;
abortIfNeeded(signal);
await new Promise<void>((resolve, reject) => {
const abortSignal = signal;
const timer = setTimeout(() => {
abortSignal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
abortSignal?.removeEventListener('abort', onAbort);
reject(abortSignal?.reason instanceof Error ? abortSignal.reason : new Error('Browser action aborted'));
};
abortSignal?.addEventListener('abort', onAbort, { once: true });
});
}
export class BrowserViewManager extends EventEmitter {
private window: BrowserWindow | null = null;
private browserSession: Session | null = null;
private tabs = new Map<string, BrowserTab>();
private tabOrder: string[] = [];
private activeTabId: string | null = null;
private attachedTabId: string | null = null;
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
private snapshotCache = new Map<string, CachedSnapshot>();
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;
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 {
if (this.browserSession) return this.browserSession;
const browserSession = session.fromPartition(BROWSER_PARTITION);
browserSession.setUserAgent(SPOOF_UA);
this.browserSession = browserSession;
return browserSession;
}
private emitState(): void {
this.emit('state-updated', this.snapshotState());
}
private getTab(tabId: string | null): BrowserTab | null {
if (!tabId) return null;
return this.tabs.get(tabId) ?? null;
}
private getActiveTab(): BrowserTab | null {
return this.getTab(this.activeTabId);
}
private invalidateSnapshot(tabId: string): void {
this.snapshotCache.delete(tabId);
}
private isEmbeddedTabUrl(url: string): boolean {
return /^https?:\/\//i.test(url) || url === 'about:blank';
}
private createView(): WebContentsView {
const view = new WebContentsView({
webPreferences: {
session: this.getSession(),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
view.webContents.setUserAgent(SPOOF_UA);
return view;
}
private wireEvents(tab: BrowserTab): void {
const { id: tabId, view } = tab;
const wc = view.webContents;
const reapplyBounds = () => {
if (
this.attachedTabId === tabId &&
this.visible &&
this.bounds.width > 0 &&
this.bounds.height > 0
) {
view.setBounds(this.bounds);
}
};
const invalidateAndEmit = () => {
this.invalidateSnapshot(tabId);
this.emitState();
};
wc.on('did-start-navigation', (_event, _url, _isInPlace, isMainFrame) => {
if (isMainFrame !== false) {
tab.domReadyAt = null;
tab.loadError = null;
}
this.invalidateSnapshot(tabId);
reapplyBounds();
});
wc.on('did-navigate', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('did-navigate-in-page', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('did-start-loading', () => {
tab.loadError = null;
this.invalidateSnapshot(tabId);
reapplyBounds();
this.emitState();
});
wc.on('did-stop-loading', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('did-finish-load', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('dom-ready', () => {
tab.domReadyAt = Date.now();
reapplyBounds();
invalidateAndEmit();
});
wc.on('did-frame-finish-load', reapplyBounds);
wc.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame && errorCode !== -3) {
const target = validatedURL || wc.getURL() || 'page';
tab.loadError = errorDescription
? `Failed to load ${target}: ${errorDescription}.`
: `Failed to load ${target}.`;
}
reapplyBounds();
invalidateAndEmit();
});
wc.on('page-title-updated', this.emitState.bind(this));
wc.setWindowOpenHandler(({ url }) => {
if (this.isEmbeddedTabUrl(url)) {
void this.newTab(url);
} else {
void shell.openExternal(url);
}
return { action: 'deny' };
});
}
private snapshotTabState(tab: BrowserTab): BrowserTabState {
const wc = tab.view.webContents;
return {
id: tab.id,
url: wc.getURL(),
title: wc.getTitle(),
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
loading: wc.isLoading(),
};
}
private syncAttachedView(): void {
if (!this.window) return;
const contentView = this.window.contentView;
const activeTab = this.getActiveTab();
if (!this.visible || !activeTab) {
const attachedTab = this.getTab(this.attachedTabId);
if (attachedTab) {
contentView.removeChildView(attachedTab.view);
}
this.attachedTabId = null;
return;
}
if (this.attachedTabId && this.attachedTabId !== activeTab.id) {
const attachedTab = this.getTab(this.attachedTabId);
if (attachedTab) {
contentView.removeChildView(attachedTab.view);
}
this.attachedTabId = null;
}
if (this.attachedTabId !== activeTab.id) {
contentView.addChildView(activeTab.view);
this.attachedTabId = activeTab.id;
}
if (this.bounds.width > 0 && this.bounds.height > 0) {
activeTab.view.setBounds(this.bounds);
}
}
private createTab(initialUrl: string): BrowserTab {
if (!this.window) {
throw new Error('BrowserViewManager: no window attached');
}
const tabId = randomUUID();
const tab: BrowserTab = {
id: tabId,
view: this.createView(),
domReadyAt: null,
loadError: null,
};
this.wireEvents(tab);
this.tabs.set(tabId, tab);
this.tabOrder.push(tabId);
this.activeTabId = tabId;
this.invalidateSnapshot(tabId);
this.syncAttachedView();
this.emitState();
const targetUrl =
initialUrl === 'about:blank'
? HOME_URL
: normalizeNavigationTarget(initialUrl);
void tab.view.webContents.loadURL(targetUrl).catch((error) => {
tab.loadError = error instanceof Error
? error.message
: `Failed to load ${targetUrl}.`;
this.emitState();
});
return tab;
}
private ensureInitialTab(): BrowserTab {
const activeTab = this.getActiveTab();
if (activeTab) return activeTab;
return this.createTab(HOME_URL);
}
private destroyTab(tab: BrowserTab): void {
this.invalidateSnapshot(tab.id);
tab.view.webContents.removeAllListeners();
if (!tab.view.webContents.isDestroyed()) {
tab.view.webContents.close();
}
}
private async waitForWebContentsSettle(
tab: BrowserTab,
signal?: AbortSignal,
idleMs = POST_ACTION_IDLE_MS,
timeoutMs = NAVIGATION_TIMEOUT_MS,
): Promise<void> {
const wc = tab.view.webContents;
const startedAt = Date.now();
let sawLoading = wc.isLoading();
while (Date.now() - startedAt < timeoutMs) {
abortIfNeeded(signal);
if (wc.isDestroyed()) return;
if (tab.loadError) {
throw new Error(tab.loadError);
}
if (tab.domReadyAt != null) {
const domReadyForMs = Date.now() - tab.domReadyAt;
const requiredIdleMs = sawLoading ? idleMs : Math.min(idleMs, 200);
if (domReadyForMs >= requiredIdleMs) return;
await sleep(Math.min(100, requiredIdleMs - domReadyForMs), signal);
continue;
}
if (wc.isLoading()) {
sawLoading = true;
await sleep(100, signal);
continue;
}
await sleep(sawLoading ? idleMs : Math.min(idleMs, 200), signal);
if (tab.loadError) {
throw new Error(tab.loadError);
}
if (!wc.isLoading() || tab.domReadyAt != null) return;
sawLoading = true;
}
}
private async executeOnActiveTab<T>(
script: string,
signal?: AbortSignal,
options?: { waitForReady?: boolean },
): Promise<T> {
abortIfNeeded(signal);
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
if (options?.waitForReady !== false) {
await this.waitForWebContentsSettle(activeTab, signal);
}
abortIfNeeded(signal);
return activeTab.view.webContents.executeJavaScript(script, true) as Promise<T>;
}
private cacheSnapshot(tabId: string, rawSnapshot: RawBrowserPageSnapshot, loading: boolean): BrowserPageSnapshot {
const snapshotId = randomUUID();
const elements: BrowserPageElement[] = rawSnapshot.elements.map((element, index) => {
const { selector, ...rest } = element;
void selector;
return {
...rest,
index: index + 1,
};
});
this.snapshotCache.set(tabId, {
snapshotId,
elements: rawSnapshot.elements.map((element, index) => ({
index: index + 1,
selector: element.selector,
})),
});
return {
snapshotId,
url: rawSnapshot.url,
title: rawSnapshot.title,
loading,
text: rawSnapshot.text,
elements,
};
}
private resolveElementSelector(tabId: string, target: ElementTarget): { ok: true; selector: string } | { ok: false; error: string } {
if (target.selector?.trim()) {
return { ok: true, selector: target.selector.trim() };
}
if (target.index == null) {
return { ok: false, error: 'Provide an element index or selector.' };
}
const cachedSnapshot = this.snapshotCache.get(tabId);
if (!cachedSnapshot) {
return { ok: false, error: 'No page snapshot is available yet. Call read-page first.' };
}
if (target.snapshotId && cachedSnapshot.snapshotId !== target.snapshotId) {
return { ok: false, error: 'The page changed since the last read-page call. Call read-page again.' };
}
const entry = cachedSnapshot.elements.find((element) => element.index === target.index);
if (!entry) {
return { ok: false, error: `No element found for index ${target.index}.` };
}
return { ok: true, selector: entry.selector };
}
setVisible(visible: boolean): void {
this.visible = visible;
if (visible) {
this.ensureInitialTab();
}
this.syncAttachedView();
}
setBounds(bounds: BrowserBounds): void {
this.bounds = bounds;
const activeTab = this.getActiveTab();
if (activeTab && this.attachedTabId === activeTab.id && this.visible) {
activeTab.view.setBounds(bounds);
}
}
async ensureActiveTabReady(signal?: AbortSignal): Promise<void> {
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
await this.waitForWebContentsSettle(activeTab, signal);
}
async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> {
try {
const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL);
return { ok: true, tabId: tab.id };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
switchTab(tabId: string): { ok: boolean } {
if (!this.tabs.has(tabId)) return { ok: false };
if (this.activeTabId === tabId) return { ok: true };
this.activeTabId = tabId;
this.syncAttachedView();
this.emitState();
return { ok: true };
}
closeTab(tabId: string): { ok: boolean } {
const tab = this.tabs.get(tabId);
if (!tab) return { ok: false };
if (this.tabOrder.length <= 1) return { ok: false };
const closingIndex = this.tabOrder.indexOf(tabId);
const nextActiveTabId =
this.activeTabId === tabId
? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null
: this.activeTabId;
if (this.attachedTabId === tabId && this.window) {
this.window.contentView.removeChildView(tab.view);
this.attachedTabId = null;
}
this.tabs.delete(tabId);
this.tabOrder = this.tabOrder.filter((id) => id !== tabId);
this.activeTabId = nextActiveTabId;
this.destroyTab(tab);
this.syncAttachedView();
this.emitState();
return { ok: true };
}
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
try {
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
this.invalidateSnapshot(activeTab.id);
await activeTab.view.webContents.loadURL(normalizeNavigationTarget(rawUrl));
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
back(): { ok: boolean } {
const activeTab = this.getActiveTab();
if (!activeTab) return { ok: false };
const history = activeTab.view.webContents.navigationHistory;
if (!history.canGoBack()) return { ok: false };
this.invalidateSnapshot(activeTab.id);
history.goBack();
return { ok: true };
}
forward(): { ok: boolean } {
const activeTab = this.getActiveTab();
if (!activeTab) return { ok: false };
const history = activeTab.view.webContents.navigationHistory;
if (!history.canGoForward()) return { ok: false };
this.invalidateSnapshot(activeTab.id);
history.goForward();
return { ok: true };
}
reload(): void {
const activeTab = this.getActiveTab();
if (!activeTab) return;
this.invalidateSnapshot(activeTab.id);
activeTab.view.webContents.reload();
}
async readPage(
options?: { maxElements?: number; maxTextLength?: number; waitForReady?: boolean },
signal?: AbortSignal,
): Promise<{ ok: boolean; page?: BrowserPageSnapshot; error?: string }> {
try {
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
const rawSnapshot = await this.executeOnActiveTab<RawBrowserPageSnapshot>(
buildReadPageScript(
options?.maxElements ?? DEFAULT_READ_MAX_ELEMENTS,
options?.maxTextLength ?? DEFAULT_READ_MAX_TEXT_LENGTH,
),
signal,
{ waitForReady: options?.waitForReady },
);
return {
ok: true,
page: this.cacheSnapshot(activeTab.id, rawSnapshot, activeTab.view.webContents.isLoading()),
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Failed to read the current page.',
};
}
}
async readPageSummary(
signal?: AbortSignal,
options?: { waitForReady?: boolean },
): Promise<BrowserPageSnapshot | null> {
const result = await this.readPage(
{
maxElements: POST_ACTION_MAX_ELEMENTS,
maxTextLength: POST_ACTION_MAX_TEXT_LENGTH,
waitForReady: options?.waitForReady,
},
signal,
);
return result.ok ? result.page ?? null : null;
}
async click(target: ElementTarget, signal?: AbortSignal): Promise<{ ok: boolean; error?: string; description?: string }> {
const activeTab = this.getActiveTab();
if (!activeTab) {
return { ok: false, error: 'No active browser tab is open.' };
}
const resolved = this.resolveElementSelector(activeTab.id, target);
if (!resolved.ok) return resolved;
try {
const result = await this.executeOnActiveTab<{
ok: boolean;
error?: string;
description?: string;
clickPoint?: {
x: number;
y: number;
};
verification?: {
before: unknown;
targetSelector: string | null;
};
}>(
buildClickScript(resolved.selector),
signal,
);
if (!result.ok) return result;
if (!result.clickPoint) {
return {
ok: false,
error: 'Could not determine where to click on the page.',
};
}
this.window?.focus();
activeTab.view.webContents.focus();
activeTab.view.webContents.sendInputEvent({
type: 'mouseMove',
x: result.clickPoint.x,
y: result.clickPoint.y,
movementX: 0,
movementY: 0,
});
activeTab.view.webContents.sendInputEvent({
type: 'mouseDown',
x: result.clickPoint.x,
y: result.clickPoint.y,
button: 'left',
clickCount: 1,
});
activeTab.view.webContents.sendInputEvent({
type: 'mouseUp',
x: result.clickPoint.x,
y: result.clickPoint.y,
button: 'left',
clickCount: 1,
});
this.invalidateSnapshot(activeTab.id);
await this.waitForWebContentsSettle(activeTab, signal);
if (result.verification) {
const verification = await this.executeOnActiveTab<{ changed: boolean; reasons: string[] }>(
buildVerifyClickScript(result.verification.targetSelector, result.verification.before),
signal,
{ waitForReady: false },
);
if (!verification.changed) {
return {
ok: false,
error: 'Click did not change the page state. Target may not be the correct control.',
description: result.description,
};
}
}
return result;
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Failed to click the element.',
};
}
}
async type(target: ElementTarget, text: string, signal?: AbortSignal): Promise<{ ok: boolean; error?: string; description?: string }> {
const activeTab = this.getActiveTab();
if (!activeTab) {
return { ok: false, error: 'No active browser tab is open.' };
}
const resolved = this.resolveElementSelector(activeTab.id, target);
if (!resolved.ok) return resolved;
try {
const result = await this.executeOnActiveTab<{ ok: boolean; error?: string; description?: string }>(
buildTypeScript(resolved.selector, text),
signal,
);
if (!result.ok) return result;
this.invalidateSnapshot(activeTab.id);
await this.waitForWebContentsSettle(activeTab, signal);
return result;
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Failed to type into the element.',
};
}
}
async press(
key: string,
target?: ElementTarget,
signal?: AbortSignal,
): Promise<{ ok: boolean; error?: string; description?: string }> {
const activeTab = this.getActiveTab();
if (!activeTab) {
return { ok: false, error: 'No active browser tab is open.' };
}
let description = 'active element';
if (target?.index != null || target?.selector?.trim()) {
const resolved = this.resolveElementSelector(activeTab.id, target);
if (!resolved.ok) return resolved;
try {
const focusResult = await this.executeOnActiveTab<{ ok: boolean; error?: string; description?: string }>(
buildFocusScript(resolved.selector),
signal,
);
if (!focusResult.ok) return focusResult;
description = focusResult.description ?? description;
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Failed to focus the element before pressing a key.',
};
}
}
try {
const wc = activeTab.view.webContents;
const keyCode = normalizeKeyCode(key);
wc.sendInputEvent({ type: 'keyDown', keyCode });
if (keyCode.length === 1) {
wc.sendInputEvent({ type: 'char', keyCode });
}
wc.sendInputEvent({ type: 'keyUp', keyCode });
this.invalidateSnapshot(activeTab.id);
await this.waitForWebContentsSettle(activeTab, signal);
return {
ok: true,
description: `${keyCode} on ${description}`,
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Failed to press the requested key.',
};
}
}
async scroll(direction: 'up' | 'down' = 'down', amount = 700, signal?: AbortSignal): Promise<{ ok: boolean; error?: string }> {
const activeTab = this.getActiveTab();
if (!activeTab) {
return { ok: false, error: 'No active browser tab is open.' };
}
try {
const offset = Math.max(1, amount) * (direction === 'up' ? -1 : 1);
const result = await this.executeOnActiveTab<{ ok: boolean; error?: string }>(
buildScrollScript(offset),
signal,
);
if (!result.ok) return result;
this.invalidateSnapshot(activeTab.id);
await sleep(250, signal);
return result;
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Failed to scroll the page.',
};
}
}
async wait(ms = 1000, signal?: AbortSignal): Promise<void> {
await sleep(ms, signal);
const activeTab = this.getActiveTab();
if (!activeTab) return;
await this.waitForWebContentsSettle(activeTab, signal);
}
getState(): BrowserState {
return this.snapshotState();
}
private snapshotState(): BrowserState {
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
return {
activeTabId: this.activeTabId,
tabs: this.tabOrder
.map((tabId) => this.tabs.get(tabId))
.filter((tab): tab is BrowserTab => tab != null)
.map((tab) => this.snapshotTabState(tab)),
};
}
}
export const browserViewManager = new BrowserViewManager();

View file

@ -44,7 +44,6 @@ export async function isConfigured(): Promise<{ configured: boolean }> {
export function setApiKey(apiKey: string): { success: boolean; error?: string } { export function setApiKey(apiKey: string): { success: boolean; error?: string } {
try { try {
composioClient.setApiKey(apiKey); composioClient.setApiKey(apiKey);
invalidateCopilotInstructionsCache();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { return {
@ -293,6 +292,20 @@ export function listConnected(): { toolkits: string[] } {
return { toolkits: composioAccountsRepo.getConnectedToolkits() }; 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. * List available Composio toolkits filtered to curated list only.
* Return type matches the ZToolkit schema from core/composio/types.ts. * 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 { ipc } from '@x/shared';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
@ -8,7 +8,6 @@ import {
listProviders, listProviders,
} from './oauth-handler.js'; } from './oauth-handler.js';
import { watcher as watcherCore, workspace } from '@x/core'; 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 { workspace as workspaceShared } from '@x/shared';
import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as mcpCore from '@x/core/dist/mcp/mcp.js';
import * as runsCore from '@x/core/dist/runs/runs.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 { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/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 { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.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 { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-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'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
@ -51,29 +44,6 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { 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 {
fetchLiveNote,
setLiveNote,
setLiveNoteActive,
deleteLiveNote,
listLiveNotes,
} from '@x/core/dist/knowledge/live-note/fileops.js';
import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js';
import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js';
import {
fetchTask,
patchTask,
createTask,
deleteTask,
listTasks,
readRunIds as readTaskRunIds,
} from '@x/core/dist/background-tasks/fileops.js';
import { browserIpcHandlers } from './browser/ipc.js';
/** /**
* Convert markdown to a styled HTML document for PDF/DOCX export. * Convert markdown to a styled HTML document for PDF/DOCX export.
@ -140,18 +110,6 @@ function markdownToHtml(markdown: string, title: string): string {
</style></head><body>${html}</body></html>` </style></head><body>${html}</body></html>`
} }
function resolveShellPath(filePath: string): string {
if (filePath.startsWith('~')) {
return path.join(os.homedir(), filePath.slice(1));
}
if (path.isAbsolute(filePath)) {
return filePath;
}
return workspace.resolveWorkspacePath(filePath);
}
type InvokeChannels = ipc.InvokeChannels; type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels; type IPCChannels = ipc.IPCChannels;
@ -313,7 +271,7 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
/** /**
* Start workspace watcher * Start workspace watcher
* Watches the configured workspace root recursively and emits change events to renderer * Watches ~/.rowboat recursively and emits change events to renderer
* *
* This should be called once when the app starts (from main.ts). * This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes * The watcher runs as a main-process service and catches ALL filesystem changes
@ -363,7 +321,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(); const windows = BrowserWindow.getAllWindows();
for (const win of windows) { for (const win of windows) {
if (!win.isDestroyed() && win.webContents) { if (!win.isDestroyed() && win.webContents) {
@ -392,32 +350,6 @@ export async function startServicesWatcher(): Promise<void> {
}); });
} }
let liveNoteAgentWatcher: (() => void) | null = null;
export function startLiveNoteAgentWatcher(): void {
if (liveNoteAgentWatcher) return;
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('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);
}
}
});
}
export function stopRunsWatcher(): void { export function stopRunsWatcher(): void {
if (runsWatcher) { if (runsWatcher) {
runsWatcher(); runsWatcher();
@ -449,16 +381,6 @@ export function setupIpcHandlers() {
// args is null for this channel (no request payload) // args is null for this channel (no request payload)
return getVersions(); return getVersions();
}, },
'app:consumePendingDeepLink': async () => {
return { url: consumePendingDeepLink() };
},
'analytics:bootstrap': async () => {
return {
installationId: getInstallationId(),
apiUrl: API_URL,
appVersion: app.getVersion(),
};
},
'workspace:getRoot': async () => { 'workspace:getRoot': async () => {
return workspace.getRoot(); return workspace.getRoot();
}, },
@ -489,38 +411,6 @@ export function setupIpcHandlers() {
'workspace:remove': async (_event, args) => { 'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts); 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) => { 'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor); return mcpCore.listTools(args.serverName, args.cursor);
}, },
@ -531,17 +421,12 @@ export function setupIpcHandlers() {
return runsCore.createRun(args); return runsCore.createRun(args);
}, },
'runs:createMessage': async (_event, args) => { 'runs:createMessage': async (_event, args) => {
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) };
}, },
'runs:authorizePermission': async (_event, args) => { 'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization); await runsCore.authorizePermission(args.runId, args.authorization);
return { success: true }; 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) => { 'runs:provideHumanInput': async (_event, args) => {
await runsCore.replyToHumanInputRequest(args.runId, args.reply); await runsCore.replyToHumanInputRequest(args.runId, args.reply);
return { success: true }; return { success: true };
@ -560,35 +445,6 @@ export function setupIpcHandlers() {
await runsCore.deleteRun(args.runId); await runsCore.deleteRun(args.runId);
return { success: true }; 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 () => { 'models:list': async () => {
if (await isSignedIn()) { if (await isSignedIn()) {
return await listGatewayModels(); return await listGatewayModels();
@ -640,20 +496,6 @@ export function setupIpcHandlers() {
const config = await repo.getConfig(); const config = await repo.getConfig();
return { enabled: config.enabled }; 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) => { 'granola:setConfig': async (_event, args) => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo'); const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled }); await repo.setConfig({ enabled: args.enabled });
@ -724,8 +566,11 @@ export function setupIpcHandlers() {
'composio:list-toolkits': async () => { 'composio:list-toolkits': async () => {
return composioHandler.listToolkits(); return composioHandler.listToolkits();
}, },
'migration:check-composio-google': async () => { 'composio:use-composio-for-google': async () => {
return qualifyAndDisconnectComposioGoogle(); return composioHandler.useComposioForGoogle();
},
'composio:use-composio-for-google-calendar': async () => {
return composioHandler.useComposioForGoogleCalendar();
}, },
// Agent schedule handlers // Agent schedule handlers
'agent-schedule:getConfig': async () => { 'agent-schedule:getConfig': async () => {
@ -762,17 +607,24 @@ export function setupIpcHandlers() {
}, },
// Shell integration handlers // Shell integration handlers
'shell:openPath': async (_event, args) => { 'shell:openPath': async (_event, args) => {
const filePath = resolveShellPath(args.path); let filePath = args.path;
if (filePath.startsWith('~')) {
filePath = path.join(os.homedir(), filePath.slice(1));
} else if (!path.isAbsolute(filePath)) {
// Workspace-relative path — resolve against ~/.rowboat/
filePath = path.join(os.homedir(), '.rowboat', filePath);
}
const error = await shell.openPath(filePath); const error = await shell.openPath(filePath);
return { error: error || undefined }; 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) => { 'shell:readFileBase64': async (_event, args) => {
const filePath = resolveShellPath(args.path); let filePath = args.path;
if (filePath.startsWith('~')) {
filePath = path.join(os.homedir(), filePath.slice(1));
} else if (!path.isAbsolute(filePath)) {
// Workspace-relative path — resolve against ~/.rowboat/
filePath = path.join(os.homedir(), '.rowboat', filePath);
}
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
if (stat.size > 10 * 1024 * 1024) { if (stat.size > 10 * 1024 * 1024) {
throw new Error('File too large (>10MB)'); throw new Error('File too large (>10MB)');
@ -791,19 +643,6 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream'; const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size }; 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 version history handlers
'knowledge:history': async (_event, args) => { 'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path); const commits = await versionHistory.getFileHistory(args.path);
@ -919,140 +758,9 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => { 'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text); 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,
};
},
'live-note:get': async (_event, args) => {
try {
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:set': async (_event, args) => {
try {
await setLiveNote(args.filePath, args.live);
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:setActive': async (_event, args) => {
try {
await setLiveNoteActive(args.filePath, args.active);
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:delete': async (_event, args) => {
try {
await deleteLiveNote(args.filePath);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:stop': async (_event, args) => {
try {
const live = await fetchLiveNote(args.filePath);
if (!live?.lastRunId) {
return { success: false, error: 'No active run for this note' };
}
await runsCore.stop(live.lastRunId, false);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'live-note:listNotes': async () => {
const notes = await listLiveNotes();
return { notes };
},
// Bg-task handlers
'bg-task:run': async (_event, args) => {
const result = await runBackgroundTask(args.slug, 'manual', args.context);
return {
success: !result.error,
runId: result.runId,
summary: result.summary,
error: result.error,
};
},
'bg-task:get': async (_event, args) => {
try {
const task = await fetchTask(args.slug);
return { success: true, task };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'bg-task:patch': async (_event, args) => {
try {
const task = await patchTask(args.slug, args.partial);
return { success: true, task };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'bg-task:create': async (_event, args) => {
try {
const { slug } = await createTask({
name: args.name,
instructions: args.instructions,
...(args.triggers ? { triggers: args.triggers } : {}),
...(args.model ? { model: args.model } : {}),
...(args.provider ? { provider: args.provider } : {}),
});
return { success: true, slug };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'bg-task:delete': async (_event, args) => {
try {
await deleteTask(args.slug);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'bg-task:stop': async (_event, args) => {
try {
const task = await fetchTask(args.slug);
if (!task?.lastRunId) {
return { success: false, error: 'No active run for this task' };
}
await runsCore.stop(task.lastRunId, false);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'bg-task:list': async (_event, args) => {
return listTasks(args);
},
'bg-task:listRunIds': async (_event, args) => {
const runIds = await readTaskRunIds(args.slug, args.limit);
return { runIds };
},
// Billing handler // Billing handler
'billing:getInfo': async () => { 'billing:getInfo': async () => {
return await getBillingInfo(); return await getBillingInfo();
}, },
// Embedded browser handlers (WebContentsView + navigation)
...browserIpcHandlers,
}); });
} }

View file

@ -1,11 +1,9 @@
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron"; import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron";
import path from "node:path"; import path from "node:path";
import { import {
setupIpcHandlers, setupIpcHandlers,
startRunsWatcher, startRunsWatcher,
startServicesWatcher, startServicesWatcher,
startLiveNoteAgentWatcher,
startBackgroundTaskAgentWatcher,
startWorkspaceWatcher, startWorkspaceWatcher,
stopRunsWatcher, stopRunsWatcher,
stopServicesWatcher, stopServicesWatcher,
@ -24,35 +22,11 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup"; import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process"; import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.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); const execAsync = promisify(exec);
@ -62,44 +36,6 @@ const __dirname = dirname(__filename);
// run this as early in the main process as possible // run this as early in the main process as possible
if (started) app.quit(); 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. // Fix PATH for packaged Electron apps on macOS/Linux.
// Packaged apps inherit a minimal environment that doesn't include paths from // 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.). // the user's shell profile (such as those provided by nvm, Homebrew, etc.).
@ -120,9 +56,7 @@ function initializeExecutionEnvironment(): void {
).trim(); ).trim();
const env = JSON.parse(stdout) as Record<string, string>; const env = JSON.parse(stdout) as Record<string, string>;
// Let the user's shell environment win for overlapping keys like PATH. process.env = { ...env, ...process.env };
// Finder/launched GUI apps on macOS often start with a stripped PATH.
process.env = { ...process.env, ...env };
} catch (error) { } catch (error) {
console.error('Failed to load shell environment', error); console.error('Failed to load shell environment', error);
} }
@ -140,29 +74,16 @@ const rendererPath = app.isPackaged
: path.join(__dirname, "../../../renderer/dist"); // Development : path.join(__dirname, "../../../renderer/dist"); // Development
console.log("rendererPath", rendererPath); console.log("rendererPath", rendererPath);
// Register custom protocol for serving built renderer files in production // Register custom protocol for serving built renderer files in production.
// AND for serving local workspace files to the renderer (images, PDFs, video). // This keeps SPA routes working when users deep link into the packaged app.
//
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
// app://<anything-else>/... → renderer SPA (existing behavior)
function registerAppProtocol() { function registerAppProtocol() {
protocol.handle("app", (request) => { protocol.handle("app", (request) => {
const url = new URL(request.url); const url = new URL(request.url);
// Workspace files: app://workspace/<rel-path> // url.pathname starts with "/"
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
let urlPath = url.pathname; let urlPath = url.pathname;
// If it's "/" or a SPA route (no extension), serve index.html
if (urlPath === "/" || !path.extname(urlPath)) { if (urlPath === "/" || !path.extname(urlPath)) {
urlPath = "/index.html"; urlPath = "/index.html";
} }
@ -181,36 +102,12 @@ protocol.registerSchemesAsPrivileged([
supportFetchAPI: true, supportFetchAPI: true,
corsEnabled: true, corsEnabled: true,
allowServiceWorkers: true, allowServiceWorkers: true,
// Required for byte-range requests so <video> seeking works. // optional but often helpful:
stream: true, // stream: true,
}, },
}, },
]); ]);
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
function configureSessionPermissions(targetSession: Session): void {
targetSession.setPermissionCheckHandler((_webContents, permission) => {
return ALLOWED_SESSION_PERMISSIONS.has(permission);
});
targetSession.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_SESSION_PERMISSIONS.has(permission));
});
// Auto-approve display media requests and route system audio as loopback.
// Electron requires a video source in the callback even if we only want audio.
// We pass the first available screen source; the renderer discards the video track.
targetSession.setDisplayMediaRequestHandler(async (_request, callback) => {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
if (sources.length === 0) {
callback({});
return;
}
callback({ video: sources[0], audio: 'loopback' });
});
}
function createWindow() { function createWindow() {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1280, width: 1280,
@ -221,24 +118,35 @@ function createWindow() {
backgroundColor: "#252525", // Prevent white flash (matches dark mode) backgroundColor: "#252525", // Prevent white flash (matches dark mode)
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 }, trafficLightPosition: { x: 12, y: 12 },
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
webPreferences: { webPreferences: {
// IMPORTANT: keep Node out of renderer // IMPORTANT: keep Node out of renderer
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
sandbox: true, sandbox: true,
preload: preloadPath, 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); // Grant microphone and display-capture permissions
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION)); session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media' || permission === 'display-capture') {
callback(true);
} else {
callback(false);
}
});
setMainWindowForDeepLinks(win); // Auto-approve display media requests and route system audio as loopback.
win.on("closed", () => setMainWindowForDeepLinks(null)); // Electron requires a video source in the callback even if we only want audio.
// We pass the first available screen source; the renderer discards the video track.
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
if (sources.length === 0) {
callback({});
return;
}
callback({ video: sources[0], audio: 'loopback' });
});
// Show window when content is ready to prevent blank screen // Show window when content is ready to prevent blank screen
win.once("ready-to-show", () => { win.once("ready-to-show", () => {
@ -263,10 +171,6 @@ function createWindow() {
} }
}); });
// Attach the embedded browser pane manager to this window.
// The WebContentsView is created lazily on first `browser:setVisible`.
browserViewManager.attach(win);
if (app.isPackaged) { if (app.isPackaged) {
win.loadURL("app://-/index.html"); win.loadURL("app://-/index.html");
} else { } else {
@ -275,10 +179,10 @@ function createWindow() {
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Register custom protocol before creating window. // Register custom protocol before creating window (for production builds)
// In production this serves the renderer SPA; in dev (and prod) it also if (app.isPackaged) {
// serves workspace files via app://workspace/<rel-path> for media previews. registerAppProtocol();
registerAppProtocol(); }
// Initialize auto-updater (only in production) // Initialize auto-updater (only in production)
if (app.isPackaged) { if (app.isPackaged) {
@ -307,18 +211,7 @@ app.whenReady().then(async () => {
// Initialize all config files before UI can access them // Initialize all config files before UI can access them
await initConfigs(); await initConfigs();
// 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(); setupIpcHandlers();
setupBrowserEventForwarding();
createWindow(); createWindow();
@ -335,30 +228,6 @@ app.whenReady().then(async () => {
// start services watcher // start services watcher
startServicesWatcher(); startServicesWatcher();
// start live-note agent event watcher (forwards bus → renderer)
startLiveNoteAgentWatcher();
// start bg-task agent event watcher (forwards bus → renderer)
startBackgroundTaskAgentWatcher();
// 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 gmail sync // start gmail sync
initGmailSync(); initGmailSync();
@ -389,17 +258,9 @@ app.whenReady().then(async () => {
// start agent notes learning service // start agent notes learning service
initAgentNotes(); initAgentNotes();
// start calendar meeting notification service (fires 1-minute warnings)
initCalendarNotifications();
// start chrome extension sync server // start chrome extension sync server
initChromeSync(); initChromeSync();
// start local sites server for iframe dashboards and other mini apps
initLocalSites().catch((error) => {
console.error('[LocalSites] Failed to start:', error);
});
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
@ -418,16 +279,4 @@ app.on("before-quit", () => {
stopWorkspaceWatcher(); stopWorkspaceWatcher();
stopRunsWatcher(); stopRunsWatcher();
stopServicesWatcher(); 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 { shell } from 'electron';
import type { Server } from 'http'; import type { Server } from 'http';
import { createAuthServer } from './auth-server.js'; 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 * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } 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'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
@ -12,15 +11,8 @@ import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gma
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
import { emitOAuthEvent } from './ipc.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 { const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
return `http://localhost:${port}/oauth/callback`;
}
/** Top-level openid-client messages that often wrap a more specific cause. */ /** Top-level openid-client messages that often wrap a more specific cause. */
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
@ -117,15 +109,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
} }
/** /**
* Get or create OAuth configuration for a provider. * 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.
*/ */
async function getProviderConfiguration( async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
provider: string,
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
credentialsOverride?: { clientId: string; clientSecret: string },
): Promise<Configuration> {
const config = await getProviderConfig(provider); const config = await getProviderConfig(provider);
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => { const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
if (config.client.mode === 'static' && config.client.clientId) { if (config.client.mode === 'static' && config.client.clientId) {
@ -157,7 +143,7 @@ async function getProviderConfiguration(
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`); console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
const clientRepo = getClientRegistrationRepo(); const clientRepo = getClientRegistrationRepo();
const existingRegistration = await clientRepo.getClientRegistration(provider); const existingRegistration = await clientRepo.getClientRegistration(provider);
if (existingRegistration) { if (existingRegistration) {
console.log(`[OAuth] ${provider}: Using existing DCR registration`); console.log(`[OAuth] ${provider}: Using existing DCR registration`);
return await oauthClient.discoverConfiguration( return await oauthClient.discoverConfiguration(
@ -166,21 +152,18 @@ async function getProviderConfiguration(
); );
} }
// Register new client with the actual redirect URI (port already bound) // Register new client
const scopes = config.scopes || []; const scopes = config.scopes || [];
const { config: oauthConfig, registration } = await oauthClient.registerClient( const { config: oauthConfig, registration } = await oauthClient.registerClient(
config.discovery.issuer, config.discovery.issuer,
[redirectUri], [REDIRECT_URI],
scopes scopes
); );
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save // Save registration for future use
const boundPort = new URL(redirectUri).port await clientRepo.saveClientRegistration(provider, registration);
? parseInt(new URL(redirectUri).port, 10) console.log(`[OAuth] ${provider}: DCR registration saved`);
: DEFAULT_CALLBACK_PORT;
await clientRepo.saveClientRegistration(provider, registration, boundPort);
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
return oauthConfig; return oauthConfig;
} }
} else { } else {
@ -188,7 +171,7 @@ async function getProviderConfiguration(
if (config.client.mode !== 'static') { if (config.client.mode !== 'static') {
throw new Error('DCR requires discovery mode "issuer", not "static"'); throw new Error('DCR requires discovery mode "issuer", not "static"');
} }
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
const { clientId, clientSecret } = await resolveClientCredentials(); const { clientId, clientSecret } = await resolveClientCredentials();
return oauthClient.createStaticConfiguration( return oauthClient.createStaticConfiguration(
@ -201,37 +184,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 * Initiate OAuth flow for a provider
*/ */
@ -247,209 +199,126 @@ export async function connectProvider(provider: string, credentials?: { clientId
if (provider === 'google') { if (provider === 'google') {
if (!credentials?.clientId || !credentials?.clientSecret) { 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.' }; 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 // Get or create OAuth configuration
// at the OAuth provider console on a fixed port — we must not scan. const config = await getProviderConfiguration(provider, credentials);
// 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());
// --- Callback server --- // Generate PKCE codes
// Declare `state` before the closure so the callback can close over its binding. const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
// The variable is assigned below, before shell.openExternal, so it is always const state = oauthClient.generateState();
// set by the time any browser request arrives.
let state = ''; // 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; let callbackHandled = false;
const { server } = await createAuthServer(8080, async (callbackUrl) => {
const { server, port: boundPort } = await createAuthServer( // Guard against duplicate callbacks (browser may send multiple requests)
startPort, if (callbackHandled) return;
async (callbackUrl) => { callbackHandled = true;
// Guard against duplicate callbacks (browser may send multiple requests) const receivedState = callbackUrl.searchParams.get('state');
if (callbackHandled) return; if (receivedState == null || receivedState === '') {
callbackHandled = true; throw new Error(
const receivedState = callbackUrl.searchParams.get('state'); 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
if (receivedState == null || receivedState === '') { );
throw new Error( }
'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' if (receivedState !== state) {
); throw new Error('Invalid state parameter - possible CSRF attack');
}
if (receivedState !== state) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
const flow = activeFlows.get(state);
if (!flow || flow.provider !== provider) {
throw new Error('Invalid OAuth flow state');
}
try {
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
const tokens = await oauthClient.exchangeCodeForTokens(
flow.config,
callbackUrl,
flow.codeVerifier,
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.
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,
});
// Trigger immediate sync for relevant providers
if (provider === 'google') {
triggerGmailSync();
triggerCalendarSync();
} else if (provider === 'fireflies-ai') {
triggerFirefliesSync();
}
// 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,
});
}
} 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 } : {}),
});
} catch (error) {
console.error('OAuth token exchange failed:', error);
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
let cause: unknown = error;
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
cause = (cause as { cause?: unknown }).cause;
if (cause != null) {
console.error('[OAuth] Caused by:', cause);
}
}
const errorMessage = getOAuthErrorMessage(error);
emitOAuthEvent({ provider, success: false, error: errorMessage });
throw error;
} finally {
// Clean up
activeFlows.delete(state);
if (activeFlow && activeFlow.state === state) {
clearTimeout(activeFlow.cleanupTimeout);
activeFlow.server.close();
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 flow = activeFlows.get(state);
const config = await getProviderConfiguration(provider, redirectUri, credentials); if (!flow || flow.provider !== provider) {
throw new Error('Invalid OAuth flow state');
}
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); try {
state = oauthClient.generateState(); // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
const tokens = await oauthClient.exchangeCodeForTokens(
flow.config,
callbackUrl,
flow.codeVerifier,
state
);
const scopes = providerConfig.scopes || []; // Save tokens and credentials
activeFlows.set(state, { codeVerifier, provider, config }); console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.upsert(provider, {
tokens,
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
error: null,
});
const authUrl = oauthClient.buildAuthorizationUrl(config, { // Trigger immediate sync for relevant providers
redirect_uri: redirectUri, if (provider === 'google') {
scope: scopes.join(' '), triggerGmailSync();
code_challenge: codeChallenge, triggerCalendarSync();
state, } else if (provider === 'fireflies-ai') {
}); triggerFirefliesSync();
// Set timeout to clean up abandoned flows (2 minutes)
const cleanupTimeout = setTimeout(() => {
if (activeFlow?.state === state) {
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
cancelActiveFlow('timed_out');
} }
}, 2 * 60 * 1000);
activeFlow = { // Emit success event to renderer
provider, emitOAuthEvent({ provider, success: true });
state, } catch (error) {
server, console.error('OAuth token exchange failed:', error);
cleanupTimeout, // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
}; let cause: unknown = error;
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
// Open in system browser (shares cookies/sessions with user's regular browser) cause = (cause as { cause?: unknown }).cause;
shell.openExternal(authUrl.toString()); if (cause != null) {
console.error('[OAuth] Caused by:', cause);
return { success: true }; }
} catch (setupError) { }
// Post-bind setup failed — close the server so the port is released and const errorMessage = getOAuthErrorMessage(error);
// a retry isn't blocked by our own zombie listener. emitOAuthEvent({ provider, success: false, error: errorMessage });
server.close(); throw error;
if (state) { } finally {
// Clean up
activeFlows.delete(state); activeFlows.delete(state);
if (activeFlow && activeFlow.state === state) {
clearTimeout(activeFlow.cleanupTimeout);
activeFlow.server.close();
activeFlow = null;
}
} }
throw setupError; });
}
// 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 minutes
// Store complete flow state for cleanup
activeFlow = {
provider,
state,
server,
cleanupTimeout,
};
// 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 (error) { } catch (error) {
console.error('OAuth connection failed:', error); console.error('OAuth connection failed:', error);
return { return {
@ -459,70 +328,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) * Disconnect a provider (clear tokens)
*/ */
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> { export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
try { try {
const oauthRepo = getOAuthRepo(); 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); await oauthRepo.delete(provider);
if (provider === 'rowboat') {
analyticsCapture('user_signed_out');
analyticsReset();
}
// Notify renderer so sidebar, voice, and billing re-check state // Notify renderer so sidebar, voice, and billing re-check state
emitOAuthEvent({ provider, success: false }); emitOAuthEvent({ provider, success: false });
return { success: true }; return { success: true };
@ -532,81 +344,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) * Get access token for a provider (internal use only)
* Refreshes token if expired * Refreshes token if expired

View file

@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js';
async function main() { async function main() {
const { id } = await runsCore.createRun({ const { id } = await runsCore.createRun({
// this expects an agent file to exist at WorkDir/agents/test-agent.md // this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md
agentId: 'test-agent', agentId: 'test-agent',
}); });
console.log(`created run: ${id}`); console.log(`created run: ${id}`);
@ -16,4 +16,4 @@ async function main() {
console.log(`created message: ${msgId}`); console.log(`created message: ${msgId}`);
} }
main(); main();

View file

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'; import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { ipc as ipcShared } from '@x/shared'; import { ipc as ipcShared } from '@x/shared';
type InvokeChannels = ipcShared.InvokeChannels; type InvokeChannels = ipcShared.InvokeChannels;
@ -55,5 +55,4 @@ contextBridge.exposeInMainWorld('ipc', ipc);
contextBridge.exposeInMainWorld('electronUtils', { contextBridge.exposeInMainWorld('electronUtils', {
getPathForFile: (file: File) => webUtils.getPathForFile(file), getPathForFile: (file: File) => webUtils.getPathForFile(file),
getZoomFactor: () => webFrame.getZoomFactor(), });
});

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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@eigenpal/docx-editor-react": "^1.0.3",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
@ -26,16 +25,14 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.22.4", "@tiptap/extension-image": "^3.16.0",
"@tiptap/extension-image": "3.22.4", "@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-link": "3.22.4", "@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-placeholder": "3.22.4", "@tiptap/extension-task-item": "^3.15.3",
"@tiptap/extension-table": "3.22.4", "@tiptap/extension-task-list": "^3.15.3",
"@tiptap/extension-task-item": "3.22.4", "@tiptap/pm": "^3.15.3",
"@tiptap/extension-task-list": "3.22.4", "@tiptap/react": "^3.15.3",
"@tiptap/pm": "3.22.4", "@tiptap/starter-kit": "^3.15.3",
"@tiptap/react": "3.22.4",
"@tiptap/starter-kit": "3.22.4",
"@x/preload": "workspace:*", "@x/preload": "workspace:*",
"@x/shared": "workspace:*", "@x/shared": "workspace:*",
"ai": "^5.0.117", "ai": "^5.0.117",
@ -43,25 +40,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "^11.14.0",
"motion": "^12.23.26", "motion": "^12.23.26",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"posthog-js": "^1.332.0", "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", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-tweet": "^3.2.2",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"remark-breaks": "^4.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^1.6.10", "streamdown": "^1.6.10",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
@ -69,7 +54,6 @@
"tiptap-markdown": "^0.9.0", "tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"yaml": "^2.8.2",
"zod": "^4.2.1" "zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {

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"> & { export type AskHumanRequestProps = ComponentProps<"div"> & {
query: string; query: string;
options?: string[];
onResponse: (response: string) => void; onResponse: (response: string) => void;
isProcessing?: boolean; isProcessing?: boolean;
}; };
@ -17,21 +16,17 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
export const AskHumanRequest = ({ export const AskHumanRequest = ({
className, className,
query, query,
options,
onResponse, onResponse,
isProcessing = false, isProcessing = false,
...props ...props
}: AskHumanRequestProps) => { }: AskHumanRequestProps) => {
const [response, setResponse] = useState(""); const [response, setResponse] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasOptions = Array.isArray(options) && options.length > 0;
useEffect(() => { useEffect(() => {
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons. // Auto-focus the textarea when component mounts
if (!hasOptions) { textareaRef.current?.focus();
textareaRef.current?.focus(); }, []);
}
}, [hasOptions]);
const handleSubmit = () => { const handleSubmit = () => {
const trimmed = response.trim(); 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>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -75,47 +65,30 @@ export const AskHumanRequest = ({
{query} {query}
</p> </p>
</div> </div>
{hasOptions ? ( <div className="space-y-2">
<div className="flex flex-wrap gap-2"> <Textarea
{options!.map((option) => ( ref={textareaRef}
<Button value={response}
key={option} onChange={(e) => setResponse(e.target.value)}
variant="outline" onKeyDown={handleKeyDown}
size="sm" placeholder="Type your response..."
onClick={() => handleOptionClick(option)} disabled={isProcessing}
disabled={isProcessing} rows={3}
className="bg-background" className="resize-none"
> />
{option} <div className="flex justify-end">
</Button> <Button
))} variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
</div> </div>
) : ( </div>
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
</div>
</div>
)}
</div> </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,6 +1,5 @@
import { isValidElement, type JSX } from 'react' import { isValidElement, type JSX } from 'react'
import { FilePathCard } from './file-path-card' import { FilePathCard } from './file-path-card'
import { MermaidRenderer } from '@/components/mermaid-renderer'
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
const { children, ...rest } = props const { children, ...rest } = props
@ -20,17 +19,6 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
return <FilePathCard filePath={text} /> return <FilePathCard filePath={text} />
} }
} }
if (
typeof childProps.className === 'string' &&
childProps.className.includes('language-mermaid')
) {
const text = typeof childProps.children === 'string'
? childProps.children.trim()
: ''
if (text) {
return <MermaidRenderer source={text} />
}
}
} }
// Passthrough for all other code blocks - return children directly // Passthrough for all other code blocks - return children directly

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -8,10 +9,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import { useState, type ComponentProps } from "react"; import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod"; import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & { export type PermissionRequestProps = ComponentProps<"div"> & {
@ -22,15 +22,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onDeny?: () => void; onDeny?: () => void;
isProcessing?: boolean; isProcessing?: boolean;
response?: 'approve' | 'deny' | null; 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 = ({ export const PermissionRequest = ({
@ -42,33 +33,26 @@ export const PermissionRequest = ({
onDeny, onDeny,
isProcessing = false, isProcessing = false,
response = null, response = null,
permission,
...props ...props
}: PermissionRequestProps) => { }: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand // 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 ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command) ? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments)) : JSON.stringify(toolCall.arguments))
: null; : null;
const filePermission = permission?.kind === "file" ? permission : null;
const isResponded = response !== null; const isResponded = response !== null;
const isApproved = response === 'approve'; 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 ( return (
<div <div
className={cn( className={cn(
"not-prose mb-4 w-full rounded-md border", "not-prose mb-4 w-full rounded-md border",
isResponded isResponded
? isApproved ? isApproved
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40" ? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30" : "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", : "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
className className
)} )}
@ -76,14 +60,17 @@ export const PermissionRequest = ({
> >
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex items-start gap-3"> <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" /> <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="flex-1 space-y-2">
<div <div className="flex items-center gap-2">
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-sm text-foreground"> <h3 className="font-semibold text-sm text-foreground">
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"} {isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
@ -93,15 +80,30 @@ export const PermissionRequest = ({
</p> </p>
</div> </div>
{isResponded && ( {isResponded && (
<ChevronDownIcon <Badge
variant="secondary"
className={cn( className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform", "shrink-0",
expanded ? "rotate-180" : "rotate-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> </div>
{showDetails && command && ( {command && (
<div className="rounded-md border bg-background/50 p-3 mt-3"> <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"> <p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Command Command
@ -111,35 +113,7 @@ export const PermissionRequest = ({
</pre> </pre>
</div> </div>
)} )}
{showDetails && filePermission && ( {!command && toolCall.arguments && (
<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 && (
<div className="rounded-md border bg-background/50 p-3 mt-3"> <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"> <p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments Arguments
@ -159,12 +133,12 @@ export const PermissionRequest = ({
size="sm" size="sm"
onClick={onApprove} onClick={onApprove}
disabled={isProcessing} disabled={isProcessing}
className={cn("flex-1", (command || filePermission) && "rounded-r-none")} className={cn("flex-1", command && "rounded-r-none")}
> >
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
Approve Approve
</Button> </Button>
{(command || filePermission) && ( {command && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button

View file

@ -91,12 +91,11 @@ export type FileMention = {
id: string; id: string;
path: string; // "knowledge/notes.md" path: string; // "knowledge/notes.md"
displayName: string; // "notes" displayName: string; // "notes"
lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions)
}; };
export type MentionsContext = { export type MentionsContext = {
mentions: FileMention[]; mentions: FileMention[];
addMention: (path: string, displayName: string, lineNumber?: number) => void; addMention: (path: string, displayName: string) => void;
removeMention: (id: string) => void; removeMention: (id: string) => void;
clearMentions: () => void; clearMentions: () => void;
}; };
@ -280,13 +279,13 @@ export function PromptInputProvider({
// ----- mentions state (for @ file mentions) // ----- mentions state (for @ file mentions)
const [mentionsList, setMentionsList] = useState<FileMention[]>([]); const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => { const addMention = useCallback((path: string, displayName: string) => {
setMentionsList((prev) => { setMentionsList((prev) => {
// Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct) // Avoid duplicates
if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) { if (prev.some((m) => m.path === path)) {
return prev; return prev;
} }
return [...prev, { id: nanoid(), path, displayName, lineNumber }]; return [...prev, { id: nanoid(), path, displayName }];
}); });
}, []); }, []);

View file

@ -1,28 +1,22 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai"; import type { ToolUIPart } from "ai";
import { import {
CheckCircleIcon,
ChevronDownIcon, ChevronDownIcon,
CircleCheck, CircleIcon,
LoaderIcon, ClockIcon,
ShieldCheckIcon, WrenchIcon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { type ComponentProps, type ReactNode, isValidElement, useState } from "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) => { const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value; if (typeof value === "string") return value;
@ -51,68 +45,51 @@ const ToolCode = ({
</pre> </pre>
); );
export type ToolAutoPermissionDetail = { export type ToolProps = ComponentProps<typeof Collapsible>;
decision: "allow";
reason: string;
};
export type ToolProps = ComponentProps<typeof Collapsible> & { export const Tool = ({ className, ...props }: ToolProps) => (
autoPermissionDetail?: ToolAutoPermissionDetail; <Collapsible
}; className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { />
const toolCard = ( );
<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
)}
{...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 = { export type ToolHeaderProps = {
title?: string; title?: string;
type: ToolUIPart["type"]; type: ToolUIPart["type"];
state: ToolUIPart["state"]; state: ToolUIPart["state"];
className?: string; 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 const getStatusBadge = (status: ToolUIPart["state"]) => {
// green check when done, a red cross on error. Shared by ToolHeader (single const labels: Record<ToolUIPart["state"], string> = {
// tools) and the tool-call group. "input-streaming": "Pending",
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => { "input-available": "Running",
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />; // @ts-expect-error state only available in AI SDK v6
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />; "approval-requested": "Awaiting Approval",
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />; "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 = ({ export const ToolHeader = ({
@ -120,39 +97,32 @@ export const ToolHeader = ({
title, title,
type, type,
state, state,
hideLeadIcon,
...props ...props
}: ToolHeaderProps) => { }: ToolHeaderProps) => (
const displayTitle = title ?? type.split("-").slice(1).join("-") <CollapsibleTrigger
className={cn(
return ( "flex w-full items-center justify-between gap-4 p-3",
<CollapsibleTrigger className
className={cn( )}
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5", {...props}
className >
)} <div className="flex items-center gap-2">
{...props} <WrenchIcon className="size-4 text-muted-foreground" />
> <span className="font-medium text-sm">
<div className="flex min-w-0 flex-1 items-center gap-2"> {title ?? type.split("-").slice(1).join("-")}
{!hideLeadIcon && getLeadIcon(state)} </span>
<span {getStatusBadge(state)}
className="min-w-0 flex-1 truncate text-left font-medium text-sm" </div>
title={displayTitle} <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
> </CollapsibleTrigger>
{displayTitle} );
</span>
</div>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
)
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>; export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => ( export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( 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 className
)} )}
{...props} {...props}
@ -246,97 +216,3 @@ export const ToolTabbedContent = ({
); );
}; };
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, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { import {
CheckCircleIcon,
ChevronDownIcon, ChevronDownIcon,
GlobeIcon, GlobeIcon,
LoaderIcon, LoaderIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
interface WebSearchResultProps { interface WebSearchResultProps {
query: string; query: string;
@ -21,219 +19,39 @@ interface WebSearchResultProps {
title?: string; 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 { function getDomain(url: string): string {
try { try {
return new URL(url).hostname.replace(/^www\./, ""); return new URL(url).hostname;
} catch { } catch {
return url; 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) { export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
const isRunning = status === "pending" || status === "running"; 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 ( return (
<Collapsible <Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
open={open} <CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
onOpenChange={setOpen} <div className="flex items-center gap-2">
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30" <GlobeIcon className="size-4 text-muted-foreground" />
> <span className="font-medium text-sm">{title}</span>
<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")} />
</div> </div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger> </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]"> <CollapsibleContent>
<div className="px-4 pb-3 space-y-3"> <div className="px-3 pb-3 space-y-3">
{/* Query */} {/* Query + result count */}
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0"> <div className="flex items-center justify-between gap-2">
<GlobeIcon className="size-3.5 shrink-0" /> <div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span className="truncate">{query}</span> <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> </div>
{/* Results list */} {/* Results list */}
@ -255,7 +73,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<img <img
src={faviconUrl(domain)} src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt="" alt=""
className="size-4 shrink-0" className="size-4 shrink-0"
/> />
@ -270,13 +88,20 @@ export function WebSearchResult({ query, results, status, title = "Searched the
</div> </div>
)} )}
{/* Status — only while the search is still running. */} {/* Status */}
{isRunning && ( <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> {isRunning ? (
<LoaderIcon className="size-3.5 animate-spin" /> <>
<span>Searching...</span> <LoaderIcon className="size-3.5 animate-spin" />
</div> <span>Searching...</span>
)} </>
) : (
<>
<CheckCircleIcon className="size-3.5 text-green-600" />
<span>Done</span>
</>
)}
</div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </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 * as React from 'react'
import { useEffect, useState, useMemo, useCallback, useRef } 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 { Badge } from '@/components/ui/badge'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command' 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> rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void> remove: (path: string) => Promise<void>
copyPath: (path: string) => 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 }[] { function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
return nodes.flatMap((n) => return nodes.flatMap((n) =>
n.kind === 'file' && n.name.endsWith('.md') n.kind === 'file' && n.name.endsWith('.md')
@ -928,10 +919,6 @@ function NoteRow({
<Copy className="mr-2 size-4" /> <Copy className="mr-2 size-4" />
Copy Path Copy Path
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={() => actions?.revealInFileManager(note.path, false)}>
<FolderOpen className="mr-2 size-4" />
Open in {getFileManagerName()}
</ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}> <ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
<Pencil className="mr-2 size-4" /> <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

@ -1,425 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
import { TabBar } from '@/components/tab-bar'
import { cn } from '@/lib/utils'
/**
* Embedded browser pane.
*
* Renders a transparent placeholder div whose bounds are reported to the
* main process via `browser:setBounds`. The actual browsing surface is an
* Electron WebContentsView layered on top of the renderer by the main
* process this component only owns the chrome (tabs, address bar, nav
* buttons) and the sizing/visibility lifecycle.
*/
interface BrowserTabState {
id: string
url: string
title: string
canGoBack: boolean
canGoForward: boolean
loading: boolean
}
interface BrowserState {
activeTabId: string | null
tabs: BrowserTabState[]
}
const EMPTY_STATE: BrowserState = {
activeTabId: null,
tabs: [],
}
const CHROME_HEIGHT = 40
const BLOCKING_OVERLAY_SLOTS = new Set([
'alert-dialog-content',
'context-menu-content',
'context-menu-sub-content',
'dialog-content',
'dropdown-menu-content',
'dropdown-menu-sub-content',
'hover-card-content',
'popover-content',
'select-content',
'sheet-content',
])
interface BrowserPaneProps {
onClose: () => void
forceHidden?: boolean
}
const getActiveTab = (state: BrowserState) =>
state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
const isVisibleOverlayElement = (el: HTMLElement) => {
const style = window.getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false
}
const rect = el.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const hasBlockingOverlay = (doc: Document) => {
const openContent = doc.querySelectorAll<HTMLElement>('[data-slot][data-state="open"]')
return Array.from(openContent).some((el) => {
const slot = el.dataset.slot
if (!slot || !BLOCKING_OVERLAY_SLOTS.has(slot)) return false
return isVisibleOverlayElement(el)
})
}
const getBrowserTabTitle = (tab: BrowserTabState) => {
const title = tab.title.trim()
if (title) return title
const url = tab.url.trim()
if (!url) return 'New tab'
try {
const parsed = new URL(url)
return parsed.hostname || parsed.href
} catch {
return url.replace(/^https?:\/\//i, '') || 'New tab'
}
}
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
const activeTabIdRef = useRef<string | null>(null)
const addressFocusedRef = useRef(false)
const viewportRef = useRef<HTMLDivElement>(null)
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
const viewVisibleRef = useRef(false)
const activeTab = getActiveTab(state)
const applyState = useCallback((next: BrowserState) => {
const previousActiveTabId = activeTabIdRef.current
activeTabIdRef.current = next.activeTabId
setState(next)
const nextActiveTab = getActiveTab(next)
if (!addressFocusedRef.current || next.activeTabId !== previousActiveTabId) {
setAddressValue(nextActiveTab?.url ?? '')
}
}, [])
useEffect(() => {
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
applyState(incoming as BrowserState)
})
void window.ipc.invoke('browser:getState', null).then((initial) => {
applyState(initial as BrowserState)
})
return cleanup
}, [applyState])
const setViewVisible = useCallback((visible: boolean) => {
if (viewVisibleRef.current === visible) return
viewVisibleRef.current = visible
void window.ipc.invoke('browser:setVisible', { visible })
}, [])
const measureBounds = useCallback(() => {
const el = viewportRef.current
if (!el) return null
const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01)
const rect = el.getBoundingClientRect()
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
const chatSidebarRect = chatSidebar?.getBoundingClientRect()
const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0
? Math.min(rect.right, chatSidebarRect.left)
: rect.right
// `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's
// native view bounds are in unzoomed window coordinates, so convert back
// using the renderer zoom factor before calling into the main process.
const left = Math.ceil(rect.left * zoomFactor)
const top = Math.ceil(rect.top * zoomFactor)
const right = Math.floor(clampedRightCss * zoomFactor)
const bottom = Math.floor(rect.bottom * zoomFactor)
const width = right - left
const height = bottom - top
if (width <= 0 || height <= 0) return null
return {
x: left,
y: top,
width,
height,
}
}, [])
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
const last = lastBoundsRef.current
if (
last &&
last.x === bounds.x &&
last.y === bounds.y &&
last.width === bounds.width &&
last.height === bounds.height
) {
return bounds
}
lastBoundsRef.current = bounds
void window.ipc.invoke('browser:setBounds', bounds)
return bounds
}, [])
const syncView = useCallback(() => {
if (forceHidden) {
lastBoundsRef.current = null
setViewVisible(false)
return null
}
const doc = viewportRef.current?.ownerDocument
if (doc && hasBlockingOverlay(doc)) {
lastBoundsRef.current = null
setViewVisible(false)
return null
}
const bounds = measureBounds()
if (!bounds) {
lastBoundsRef.current = null
setViewVisible(false)
return null
}
pushBounds(bounds)
setViewVisible(true)
return bounds
}, [forceHidden, measureBounds, pushBounds, setViewVisible])
useEffect(() => {
syncView()
}, [activeTab?.id, activeTab?.loading, activeTab?.url, syncView])
useEffect(() => {
let cancelled = false
const rafId = requestAnimationFrame(() => {
if (cancelled) return
syncView()
})
return () => {
cancelled = true
cancelAnimationFrame(rafId)
lastBoundsRef.current = null
setViewVisible(false)
}
}, [setViewVisible, syncView])
useEffect(() => {
const el = viewportRef.current
if (!el) return
const sidebarInset = el.closest<HTMLElement>('[data-slot="sidebar-inset"]')
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
const documentElement = el.ownerDocument.documentElement
let pendingRaf: number | null = null
const schedule = () => {
if (pendingRaf !== null) return
pendingRaf = requestAnimationFrame(() => {
pendingRaf = null
syncView()
})
}
const ro = new ResizeObserver(schedule)
ro.observe(el)
if (sidebarInset) ro.observe(sidebarInset)
if (chatSidebar) ro.observe(chatSidebar)
ro.observe(documentElement)
return () => {
if (pendingRaf !== null) cancelAnimationFrame(pendingRaf)
ro.disconnect()
}
}, [syncView])
useEffect(() => {
const doc = viewportRef.current?.ownerDocument
if (!doc?.body) return
let pendingRaf: number | null = null
const schedule = () => {
if (pendingRaf !== null) return
pendingRaf = requestAnimationFrame(() => {
pendingRaf = null
syncView()
})
}
const observer = new MutationObserver(schedule)
observer.observe(doc.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['data-state', 'style', 'hidden', 'aria-hidden', 'open'],
})
return () => {
if (pendingRaf !== null) cancelAnimationFrame(pendingRaf)
observer.disconnect()
}
}, [syncView])
const handleNewTab = useCallback(() => {
void window.ipc.invoke('browser:newTab', {}).then((res) => {
const result = res as { ok: boolean; error?: string }
if (!result.ok && result.error) {
console.error('browser:newTab failed', result.error)
}
})
}, [])
const handleSwitchTab = useCallback((tabId: string) => {
void window.ipc.invoke('browser:switchTab', { tabId })
}, [])
const handleCloseTab = useCallback((tabId: string) => {
void window.ipc.invoke('browser:closeTab', { tabId })
}, [])
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
e.preventDefault()
const trimmed = addressValue.trim()
if (!trimmed) return
void window.ipc.invoke('browser:navigate', { url: trimmed }).then((res) => {
const result = res as { ok: boolean; error?: string }
if (!result.ok && result.error) {
console.error('browser:navigate failed', result.error)
}
})
}, [addressValue])
const handleBack = useCallback(() => {
void window.ipc.invoke('browser:back', null)
}, [])
const handleForward = useCallback(() => {
void window.ipc.invoke('browser:forward', null)
}, [])
const handleReload = useCallback(() => {
void window.ipc.invoke('browser:reload', null)
}, [])
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex h-9 shrink-0 items-stretch border-b border-border bg-sidebar">
<TabBar
tabs={state.tabs}
activeTabId={state.activeTabId ?? ''}
getTabTitle={getBrowserTabTitle}
getTabId={(tab) => tab.id}
onSwitchTab={handleSwitchTab}
onCloseTab={handleCloseTab}
layout="scroll"
/>
<button
type="button"
onClick={handleNewTab}
className="flex h-9 w-9 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="New browser tab"
>
<Plus className="size-4" />
</button>
</div>
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
style={{ minHeight: CHROME_HEIGHT }}
>
<button
type="button"
onClick={handleBack}
disabled={!activeTab?.canGoBack}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
activeTab?.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Back"
>
<ArrowLeft className="size-4" />
</button>
<button
type="button"
onClick={handleForward}
disabled={!activeTab?.canGoForward}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
activeTab?.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Forward"
>
<ArrowRight className="size-4" />
</button>
<button
type="button"
onClick={handleReload}
disabled={!activeTab}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
activeTab ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Reload"
>
{activeTab?.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
</button>
<form onSubmit={handleSubmitAddress} className="flex-1 min-w-0">
<input
type="text"
value={addressValue}
onChange={(e) => setAddressValue(e.target.value)}
onFocus={(e) => {
addressFocusedRef.current = true
e.currentTarget.select()
}}
onBlur={() => {
addressFocusedRef.current = false
setAddressValue(activeTab?.url ?? '')
}}
placeholder="Enter URL or search..."
className={cn(
'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground',
'placeholder:text-muted-foreground/60',
'focus:border-border focus:outline-hidden',
)}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
/>
</form>
<button
type="button"
onClick={onClose}
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Close browser"
>
<X className="size-4" />
</button>
</div>
<div
ref={viewportRef}
className="relative min-h-0 min-w-0 flex-1"
data-browser-viewport
/>
</div>
)
}

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, FileSpreadsheet,
FileText, FileText,
FileVideo, FileVideo,
FolderCheck,
FolderClock,
FolderCog,
FolderOpen,
Globe, Globe,
Headphones, Headphones,
ImagePlus,
LoaderIcon, LoaderIcon,
Mic, Mic,
Plus, Plus,
ShieldCheck,
Square, Square,
Terminal,
X, X,
} from 'lucide-react' } from 'lucide-react'
@ -30,12 +23,8 @@ import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import {
@ -67,12 +56,6 @@ export type StagedAttachment = {
} }
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB 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> = { const providerDisplayNames: Record<string, string> = {
@ -86,27 +69,13 @@ const providerDisplayNames: Record<string, string> = {
rowboat: 'Rowboat', rowboat: 'Rowboat',
} }
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
interface ConfiguredModel { interface ConfiguredModel {
provider: ProviderName flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
model: string model: string
} apiKey?: string
baseURL?: string
type RecentWorkDir = { headers?: Record<string, string>
path: string knowledgeGraphModel?: string
lastUsedAt: number
}
export interface SelectedModel {
provider: string
model: string
}
export type PermissionMode = 'manual' | 'auto'
function getSelectedModelDisplayName(model: string) {
return model.split('/').pop() || model
} }
function getAttachmentIcon(kind: AttachmentIconKind) { 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 { 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 onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
@ -229,12 +120,6 @@ interface ChatInputInnerProps {
ttsMode?: 'summary' | 'full' ttsMode?: 'summary' | 'full'
onToggleTts?: () => void onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => 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({ function ChatInputInner({
@ -260,9 +145,6 @@ function ChatInputInner({
ttsMode, ttsMode,
onToggleTts, onToggleTts,
onTtsModeChange, onTtsModeChange,
onSelectedModelChange,
workDir = null,
onWorkDirChange,
}: ChatInputInnerProps) { }: ChatInputInnerProps) {
const controller = usePromptInputController() const controller = usePromptInputController()
const message = controller.textInput.value const message = controller.textInput.value
@ -273,42 +155,9 @@ function ChatInputInner({
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([]) const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
const [activeModelKey, setActiveModelKey] = useState('') const [activeModelKey, setActiveModelKey] = useState('')
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
const [searchEnabled, setSearchEnabled] = useState(false) const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = 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 // Check Rowboat sign-in state
useEffect(() => { useEffect(() => {
@ -327,20 +176,42 @@ function ChatInputInner({
return cleanup return cleanup
}, []) }, [])
// Load the list of models the user can choose from. // Load model config (gateway when signed in, local config when BYOK)
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
const loadModelConfig = useCallback(async () => { const loadModelConfig = useCallback(async () => {
try { try {
if (isRowboatConnected) { if (isRowboatConnected) {
// Fetch gateway models
const listResult = await window.ipc.invoke('models:list', null) const listResult = await window.ipc.invoke('models:list', null)
const rowboatProvider = listResult.providers?.find( const rowboatProvider = listResult.providers?.find(
(p: { id: string }) => p.id === 'rowboat' (p: { id: string }) => p.id === 'rowboat'
) )
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( 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) setConfiguredModels(models)
const activeKey = defaultModel
? `rowboat/${defaultModel}`
: models[0] ? `rowboat/${models[0].model}` : ''
if (activeKey) setActiveModelKey(activeKey)
} else { } else {
// BYOK: read from local models.json
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data) const parsed = JSON.parse(result.data)
const models: ConfiguredModel[] = [] const models: ConfiguredModel[] = []
@ -352,12 +223,32 @@ function ChatInputInner({
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) { for (const model of allModels) {
if (model) { 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) setConfiguredModels(models)
if (defaultKey) {
setActiveModelKey(defaultKey)
}
} }
} catch { } catch {
// No config yet // No config yet
@ -375,147 +266,6 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler) return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig]) }, [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) // Check search tool availability (exa or signed-in via gateway)
useEffect(() => { useEffect(() => {
const checkSearch = async () => { const checkSearch = async () => {
@ -534,15 +284,40 @@ function ChatInputInner({
checkSearch() checkSearch()
}, [isActive, isRowboatConnected]) }, [isActive, isRowboatConnected])
// Selecting a model affects only the *next* run created from this tab. const handleModelChange = useCallback(async (key: string) => {
// Once a run exists, model is frozen on the run and the dropdown is read-only. const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
const handleModelChange = useCallback((key: string) => {
if (lockedModel) return
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
if (!entry) return if (!entry) return
setActiveModelKey(key) 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. // Restore the tab draft when this input mounts.
useEffect(() => { useEffect(() => {
@ -600,15 +375,12 @@ function ChatInputInner({
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!canSubmit) return if (!canSubmit) return
// codeMode is sticky per conversation — don't reset after send. onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
controller.textInput.clear() controller.textInput.clear()
controller.mentions.clearMentions() controller.mentions.clearMentions()
setAttachments([]) setAttachments([])
// Web search toggle stays on for the rest of the chat session; the user setSearchEnabled(false)
// turns it off explicitly. (Not persisted across app restarts.) }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -647,14 +419,8 @@ function ChatInputInner({
} }
}, [addFiles, isActive]) }, [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 ( 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 && ( {attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3"> <div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((attachment) => { {attachments.map((attachment) => {
@ -758,246 +524,38 @@ function ChatInputInner({
/> />
</div> </div>
<div className="flex items-center gap-2 px-4 pb-3"> <div className="flex items-center gap-2 px-4 pb-3">
<DropdownMenu> <button
<Tooltip> type="button"
<TooltipTrigger asChild> onClick={() => fileInputRef.current?.click()}
<DropdownMenuTrigger asChild> 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"
<button aria-label="Attach files"
type="button" >
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" <Plus className="h-4 w-4" />
aria-label="Add" </button>
>
<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 && ( {searchAvailable && (
<button searchEnabled ? (
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'
)}
>
<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>
</button>
)}
<Tooltip>
<TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={() => { onClick={() => setSearchEnabled(false)}
if (runId) return 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"
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" /> <Globe className="h-3.5 w-3.5" />
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span> <span className="text-xs font-medium">Search</span>
<X className="h-3 w-3" />
</button> </button>
</TooltipTrigger> ) : (
<TooltipContent side="top"> <button
{runId type="button"
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}` onClick={() => setSearchEnabled(true)}
: permissionMode === 'auto' 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"
? 'Auto-permission on — click for manual approval prompts' aria-label="Search"
: 'Manual approval prompts — click for auto-permission'} >
</TooltipContent> <Globe className="h-4 w-4" />
</Tooltip> </button>
{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)}
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"
>
<Terminal className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip>
))}
<div className="flex-1" /> <div className="flex-1" />
{lockedModel ? ( {configuredModels.length > 0 && (
<span
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
>
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
</span>
) : configuredModels.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <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" 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"> <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> </span>
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
@ -1013,18 +571,18 @@ function ChatInputInner({
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}> <DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
{configuredModels.map((m) => { {configuredModels.map((m) => {
const key = `${m.provider}/${m.model}` const key = `${m.flavor}/${m.model}`
return ( return (
<DropdownMenuRadioItem key={key} value={key}> <DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m.model}</span> <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> </DropdownMenuRadioItem>
) )
})} })}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} )}
{onToggleTts && ttsAvailable && ( {onToggleTts && ttsAvailable && (
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Tooltip> <Tooltip>
@ -1149,7 +707,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[] knowledgeFiles: string[]
recentFiles: string[] recentFiles: string[]
visibleFiles: 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 onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
@ -1171,9 +729,6 @@ export interface ChatInputWithMentionsProps {
ttsMode?: 'summary' | 'full' ttsMode?: 'summary' | 'full'
onToggleTts?: () => void onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void onTtsModeChange?: (mode: 'summary' | 'full') => void
onSelectedModelChange?: (model: SelectedModel | null) => void
workDir?: string | null
onWorkDirChange?: (value: string | null) => void
} }
export function ChatInputWithMentions({ export function ChatInputWithMentions({
@ -1202,9 +757,6 @@ export function ChatInputWithMentions({
ttsMode, ttsMode,
onToggleTts, onToggleTts,
onTtsModeChange, onTtsModeChange,
onSelectedModelChange,
workDir,
onWorkDirChange,
}: ChatInputWithMentionsProps) { }: ChatInputWithMentionsProps) {
return ( return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}> <PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
@ -1231,9 +783,6 @@ export function ChatInputWithMentions({
ttsMode={ttsMode} ttsMode={ttsMode}
onToggleTts={onToggleTts} onToggleTts={onToggleTts}
onTtsModeChange={onTtsModeChange} onTtsModeChange={onTtsModeChange}
onSelectedModelChange={onSelectedModelChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}
/> />
</PromptInputProvider> </PromptInputProvider>
) )

View file

@ -1,21 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react' import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' 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 { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
ConversationEmptyState,
ConversationScrollButton, ConversationScrollButton,
} from '@/components/ai-elements/conversation' } from '@/components/ai-elements/conversation'
import { import {
@ -24,24 +16,19 @@ import {
MessageResponse, MessageResponse,
} from '@/components/ai-elements/message' } from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer' 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 { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request' 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 { 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 { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { defaultRemarkPlugins } from 'streamdown' import { TabBar, type ChatTab } from '@/components/tab-bar'
import remarkBreaks from 'remark-breaks' import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
import { type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { useSidebar } from '@/components/ui/sidebar'
import { wikiLabel } from '@/lib/wiki-links' import { wikiLabel } from '@/lib/wiki-links'
import type { ChatPaneSize } from '@/contexts/theme-context'
import { import {
type ChatViewportAnchorState, type ChatViewportAnchorState,
type ChatTabViewState, type ChatTabViewState,
@ -51,47 +38,62 @@ import {
getWebSearchCardData, getWebSearchCardData,
getComposioConnectCardData, getComposioConnectCardData,
getToolDisplayName, getToolDisplayName,
groupConversationItems,
isChatMessage, isChatMessage,
isErrorMessage, isErrorMessage,
isToolCall, isToolCall,
isToolGroup,
normalizeToolInput, normalizeToolInput,
normalizeToolOutput, normalizeToolOutput,
parseAttachedFiles, parseAttachedFiles,
toToolState, toToolState,
} from '@/lib/chat-conversation' } from '@/lib/chat-conversation'
import { matchBillingError } from '@/lib/billing-error'
const streamdownComponents = { pre: MarkdownPreOverride } const streamdownComponents = { pre: MarkdownPreOverride }
// Render user messages with markdown so bullets, bold, links, etc. survive the /* ─── Billing error helpers ─── */
// round-trip from the input textarea. `remarkBreaks` turns single newlines
// into <br> so typed line breaks are preserved without requiring blank lines.
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { const BILLING_ERROR_PATTERNS = [
const ref = useRef<HTMLPreElement>(null) {
const stickToBottom = useRef(true) 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
}
function BillingErrorCTA({ label }: { label: string }) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const el = ref.current window.ipc.invoke('account:getRowboat', null)
if (el && stickToBottom.current) { .then((account: any) => setAppUrl(account.config?.appUrl ?? null))
el.scrollTop = el.scrollHeight .catch(() => {})
}
}, [children])
const handleScroll = useCallback(() => {
const el = ref.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
stickToBottom.current = atBottom
}, []) }, [])
if (!appUrl) return null
return ( return (
<pre ref={ref} onScroll={handleScroll} className={className}> <button
{children} onClick={() => window.open(`${appUrl}?intent=upgrade`)}
</pre> 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 +128,13 @@ interface ChatSidebarProps {
defaultWidth?: number defaultWidth?: number
isOpen?: boolean isOpen?: boolean
isMaximized?: boolean isMaximized?: boolean
placement?: 'middle' | 'right'
paneSize?: ChatPaneSize
className?: string
chatTabs: ChatTab[] chatTabs: ChatTab[]
activeChatTabId: string activeChatTabId: string
getChatTabTitle: (tab: ChatTab) => string getChatTabTitle: (tab: ChatTab) => string
isChatTabProcessing: (tab: ChatTab) => boolean
onSwitchChatTab: (tabId: string) => void
onCloseChatTab: (tabId: string) => void
onNewChatTab: () => void onNewChatTab: () => void
recentRuns?: { id: string; title?: string; createdAt: string }[]
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenFullScreen?: () => void onOpenFullScreen?: () => void
conversation: ConversationItem[] conversation: ConversationItem[]
currentAssistantMessage: string currentAssistantMessage: string
@ -144,7 +143,7 @@ interface ChatSidebarProps {
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
onStop?: () => void 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[] knowledgeFiles?: string[]
recentFiles?: string[] recentFiles?: string[]
visibleFiles?: string[] visibleFiles?: string[]
@ -153,20 +152,15 @@ interface ChatSidebarProps {
onPresetMessageConsumed?: () => void onPresetMessageConsumed?: () => void
getInitialDraft?: (tabId: string) => string | undefined getInitialDraft?: (tabId: string) => string | undefined
onDraftChangeForTab?: (tabId: string, text: string) => void 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'] pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests']
permissionResponses?: ChatTabViewState['permissionResponses'] permissionResponses?: ChatTabViewState['permissionResponses']
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean isToolOpenForTab?: (tabId: string, toolId: string) => boolean
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
onOpenKnowledgeFile?: (path: string) => void onOpenKnowledgeFile?: (path: string) => void
onActivate?: () => void onActivate?: () => void
collapsedLeftPaddingPx?: number
// Voice / TTS props // Voice / TTS props
isRecording?: boolean isRecording?: boolean
recordingText?: string recordingText?: string
@ -187,16 +181,13 @@ export function ChatSidebar({
defaultWidth = DEFAULT_WIDTH, defaultWidth = DEFAULT_WIDTH,
isOpen = true, isOpen = true,
isMaximized = false, isMaximized = false,
placement = 'right',
paneSize = 'chat-smaller',
className,
chatTabs, chatTabs,
activeChatTabId, activeChatTabId,
getChatTabTitle, getChatTabTitle,
isChatTabProcessing,
onSwitchChatTab,
onCloseChatTab,
onNewChatTab, onNewChatTab,
recentRuns = [],
onSelectRun,
onOpenChatHistory,
onOpenFullScreen, onOpenFullScreen,
conversation, conversation,
currentAssistantMessage, currentAssistantMessage,
@ -214,20 +205,15 @@ export function ChatSidebar({
onPresetMessageConsumed, onPresetMessageConsumed,
getInitialDraft, getInitialDraft,
onDraftChangeForTab, onDraftChangeForTab,
onSelectedModelChangeForTab,
workDirByTab = {},
onWorkDirChangeForTab,
pendingAskHumanRequests = new Map(), pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(), allPermissionRequests = new Map(),
permissionResponses = new Map(), permissionResponses = new Map(),
autoPermissionDecisions = new Map(),
onPermissionResponse, onPermissionResponse,
onAskHumanResponse, onAskHumanResponse,
isToolOpenForTab, isToolOpenForTab,
onToolOpenChangeForTab, onToolOpenChangeForTab,
onOpenKnowledgeFile, onOpenKnowledgeFile,
onActivate, onActivate,
collapsedLeftPaddingPx = 196,
isRecording, isRecording,
recordingText, recordingText,
recordingState, recordingState,
@ -242,7 +228,6 @@ export function ChatSidebar({
onTtsModeChange, onTtsModeChange,
onComposioConnected, onComposioConnected,
}: ChatSidebarProps) { }: ChatSidebarProps) {
const { state: sidebarState } = useSidebar()
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const [showContent, setShowContent] = useState(isOpen) const [showContent, setShowContent] = useState(isOpen)
@ -253,8 +238,6 @@ export function ChatSidebar({
const startWidthRef = useRef(0) const startWidthRef = useRef(0)
const prevIsMaximizedRef = useRef(isMaximized) const prevIsMaximizedRef = useRef(isMaximized)
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
const isMiddlePlacement = placement === 'middle'
const isResizable = paneSize === 'chat-smaller'
const getMaxAllowedWidth = useCallback(() => { const getMaxAllowedWidth = useCallback(() => {
if (typeof window === 'undefined') return MAX_WIDTH if (typeof window === 'undefined') return MAX_WIDTH
@ -315,9 +298,7 @@ export function ChatSidebar({
setIsResizing(true) setIsResizing(true)
const handleMouseMove = (event: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
const delta = isMiddlePlacement const delta = startXRef.current - event.clientX
? event.clientX - startXRef.current
: startXRef.current - event.clientX
const maxAllowedWidth = getMaxAllowedWidth() const maxAllowedWidth = getMaxAllowedWidth()
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth)) setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
} }
@ -330,7 +311,7 @@ export function ChatSidebar({
document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
}, [width, getMaxAllowedWidth, isMiddlePlacement]) }, [width, getMaxAllowedWidth])
const activeTabState = useMemo<ChatTabViewState>(() => ({ const activeTabState = useMemo<ChatTabViewState>(() => ({
runId: runId ?? null, runId: runId ?? null,
@ -339,7 +320,6 @@ export function ChatSidebar({
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
}), [ }), [
runId, runId,
conversation, conversation,
@ -347,38 +327,15 @@ export function ChatSidebar({
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
autoPermissionDecisions,
]) ])
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), []) const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getTabState = useCallback((tabId: string): ChatTabViewState => { const getTabState = useCallback((tabId: string): ChatTabViewState => {
if (tabId === activeChatTabId) return activeTabState if (tabId === activeChatTabId) return activeTabState
return chatTabStates[tabId] ?? emptyTabState return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const activeRunId = activeTabState.runId const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
const handleDownloadChatLog = useCallback(async () => {
if (!activeRunId) {
toast.error('No chat log available yet')
return
}
try { const renderConversationItem = (item: ConversationItem, tabId: string) => {
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 } },
) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) { if (item.attachments && item.attachments.length > 0) {
@ -388,14 +345,7 @@ export function ChatSidebar({
<ChatMessageAttachments attachments={item.attachments} /> <ChatMessageAttachments attachments={item.attachments} />
</MessageContent> </MessageContent>
{item.content && ( {item.content && (
<MessageContent> <MessageContent>{item.content}</MessageContent>
<MessageResponse
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{item.content}
</MessageResponse>
</MessageContent>
)} )}
</Message> </Message>
) )
@ -416,12 +366,7 @@ export function ChatSidebar({
))} ))}
</div> </div>
)} )}
<MessageResponse {message}
components={streamdownComponents}
remarkPlugins={userMessageRemarkPlugins}
>
{message}
</MessageResponse>
</MessageContent> </MessageContent>
</Message> </Message>
) )
@ -471,25 +416,29 @@ export function ChatSidebar({
key={item.id} key={item.id}
open={isToolOpenForTab?.(tabId, item.id) ?? false} open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
autoPermissionDetail={options?.autoPermissionDetail}
> >
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} /> <ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent> <ToolContent>
{item.streamingOutput ? ( <ToolTabbedContent input={input} output={output} errorText={errorText} />
<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> </ToolContent>
</Tool> </Tool>
) )
} }
if (isErrorMessage(item)) { if (isErrorMessage(item)) {
if (matchBillingError(item.message)) { const billingError = matchBillingError(item.message)
return null 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 ( return (
<Message key={item.id} from="assistant" data-message-id={item.id}> <Message key={item.id} from="assistant" data-message-id={item.id}>
@ -512,32 +461,25 @@ export function ChatSidebar({
// not add extra width to the right and overflow the app viewport. // not add extra width to the right and overflow the app viewport.
return { width: 0, flex: '1 1 auto' } 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' } return { width, flex: '0 0 auto' }
}, [isOpen, isMaximized, paneSize, width]) }, [isOpen, isMaximized, width])
return ( return (
<div <div
ref={paneRef} ref={paneRef}
data-chat-sidebar-root
onMouseDownCapture={onActivate} onMouseDownCapture={onActivate}
onFocusCapture={onActivate} onFocusCapture={onActivate}
className={cn( className={cn(
'relative flex min-w-0 flex-col overflow-hidden bg-background', 'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border', !isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
className
)} )}
style={paneStyle} style={paneStyle}
> >
{!isMaximized && isResizable && ( {!isMaximized && (
<div <div
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
className={cn( className={cn(
'absolute inset-y-0 z-20 w-4 cursor-col-resize', 'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors', 'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
'hover:after:bg-sidebar-border', 'hover:after:bg-sidebar-border',
isResizing && 'after:bg-primary' isResizing && 'after:bg-primary'
@ -547,53 +489,29 @@ export function ChatSidebar({
{showContent && ( {showContent && (
<> <>
<header <header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar" <TabBar
style={{ tabs={chatTabs}
paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined, activeTabId={activeChatTabId}
paddingRight: isMaximized ? 12 : undefined, getTabTitle={getChatTabTitle}
transition: isMaximized ? 'padding-left 200ms linear' : undefined, getTabId={(tab) => tab.id}
}} isProcessing={isChatTabProcessing}
> onSwitchTab={onSwitchChatTab}
<ChatHeader onCloseTab={onCloseChatTab}
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}
/> />
<DropdownMenu> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <Button
<DropdownMenuTrigger asChild> variant="ghost"
<Button size="icon"
variant="ghost" onClick={onNewChatTab}
size="icon" className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Chat options"
>
<MoreHorizontal className="size-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunId}
onSelect={() => {
void handleDownloadChatLog()
}}
> >
<Bug className="size-4" /> <SquarePen className="size-5" />
Download chat log </Button>
</DropdownMenuItem> </TooltipTrigger>
</DropdownMenuContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</DropdownMenu> </Tooltip>
{onOpenFullScreen && ( {onOpenFullScreen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -602,14 +520,14 @@ export function ChatSidebar({
size="icon" size="icon"
onClick={onOpenFullScreen} onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" 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 {isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
</Button> </Button>
</TooltipTrigger> </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> </Tooltip>
)} )}
</header> </header>
@ -638,73 +556,31 @@ export function ChatSidebar({
anchorRequestKey={viewportAnchors[tab.id]?.requestKey} anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
className="relative flex-1" className="relative flex-1"
> >
<ConversationContent className={cn( <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'}>
'mx-auto w-full max-w-4xl px-3',
tabHasConversation ? 'pb-28' : 'pb-0',
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
)}>
{!tabHasConversation ? ( {!tabHasConversation ? (
<ChatEmptyState <ConversationEmptyState className="h-auto">
wide={isMaximized} <div className="text-sm text-muted-foreground">Ask anything...</div>
recentRuns={recentRuns} </ConversationEmptyState>
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
) : ( ) : (
<> <>
{groupConversationItems( {tabState.conversation.map((item) => {
tabState.conversation, const rendered = renderConversationItem(item, tab.id)
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) if (isToolCall(item) && onPermissionResponse) {
).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
const permRequest = tabState.allPermissionRequests.get(item.id) const permRequest = tabState.allPermissionRequests.get(item.id)
if (deniedAutoDecision || (permRequest && onPermissionResponse)) { if (permRequest) {
const response = tabState.permissionResponses.get(item.id) || null const response = tabState.permissionResponses.get(item.id) || null
return ( return (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{deniedAutoDecision && (
<AutoPermissionDecision
toolCall={deniedAutoDecision.toolCall}
permission={deniedAutoDecision.permission}
decision={deniedAutoDecision.decision}
reason={deniedAutoDecision.reason}
/>
)}
{permRequest && onPermissionResponse && (
<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')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
)}
{rendered} {rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
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')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
</React.Fragment> </React.Fragment>
) )
} }
@ -749,6 +625,9 @@ export function ChatSidebar({
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg"> <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="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"> <div className="mx-auto w-full max-w-4xl px-3">
{!hasConversation && (
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
)}
{chatTabs.map((tab) => { {chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId const isActive = tab.id === activeChatTabId
const tabState = getTabState(tab.id) const tabState = getTabState(tab.id)
@ -776,9 +655,6 @@ export function ChatSidebar({
runId={tabState.runId} runId={tabState.runId}
initialDraft={getInitialDraft?.(tab.id)} initialDraft={getInitialDraft?.(tab.id)}
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} 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} isRecording={isActive && isRecording}
recordingText={isActive ? recordingText : undefined} recordingText={isActive ? recordingText : undefined}
recordingState={isActive ? recordingState : 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 <Button
variant="default" variant="default"
size="sm" 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" className="h-7 px-2 text-xs"
> >
Reconnect 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, FileTextIcon,
FileIcon, FileIcon,
FileTypeIcon, FileTypeIcon,
Radio,
} from 'lucide-react' } from 'lucide-react'
import { import {
DropdownMenu, DropdownMenu,
@ -43,21 +42,6 @@ interface EditorToolbarProps {
onSelectionHighlight?: (range: { from: number; to: number } | null) => void onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void onImageUpload?: (file: File) => Promise<void> | void
onExport?: (format: 'md' | 'pdf' | 'docx') => 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({ export function EditorToolbar({
@ -65,8 +49,6 @@ export function EditorToolbar({
onSelectionHighlight, onSelectionHighlight,
onImageUpload, onImageUpload,
onExport, onExport,
onOpenLiveNote,
liveState,
}: EditorToolbarProps) { }: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
@ -403,19 +385,6 @@ export function EditorToolbar({
</DropdownMenu> </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> </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 })) 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[]> = {} const record: Record<string, string | string[]> = {}
for (const { key, value } of fields) { for (const { key, value } of fields) {
if (key.trim()) record[key.trim()] = value if (key.trim()) record[key.trim()] = value
} }
return buildFrontmatter(record, preserveRaw) return buildFrontmatter(record)
} }
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
@ -45,12 +45,10 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
}, [editingNewKey]) }, [editingNewKey])
const commit = useCallback((updated: FieldEntry[]) => { const commit = useCallback((updated: FieldEntry[]) => {
// Use the latest raw seen as the preserve-source so structured keys const newRaw = fieldsToRaw(updated)
// (like `live:`) survive a round-trip through this UI.
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
lastCommittedRaw.current = newRaw lastCommittedRaw.current = newRaw
onRawChange(newRaw) onRawChange(newRaw)
}, [onRawChange, raw]) }, [onRawChange])
// For scalar fields: update local state immediately, commit on blur // For scalar fields: update local state immediately, commit on blur
const updateLocalValue = useCallback((index: number, newValue: string) => { 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 { 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 { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
@ -7,22 +7,17 @@ import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list' import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item' import TaskItem from '@tiptap/extension-task-item'
import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block' import { TaskBlockExtension } from '@/extensions/task-block'
import { PromptBlockExtension } from '@/extensions/prompt-block'
import { ImageBlockExtension } from '@/extensions/image-block' import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block' import { EmbedBlockExtension } from '@/extensions/embed-block'
import { IframeBlockExtension } from '@/extensions/iframe-block'
import { ChartBlockExtension } from '@/extensions/chart-block' import { ChartBlockExtension } from '@/extensions/chart-block'
import { TableBlockExtension } from '@/extensions/table-block' import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block' import { CalendarBlockExtension } from '@/extensions/calendar-block'
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block' import { EmailBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { Markdown } from 'tiptap-markdown' import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react' import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
// Zero-width space used as invisible marker for blank lines // Zero-width space used as invisible marker for blank lines
@ -58,248 +53,164 @@ function postprocessMarkdown(markdown: string): string {
}).join('\n') }).join('\n')
} }
type JsonNode = { // Custom function to get markdown that preserves empty paragraphs as blank lines
type?: string function getMarkdownWithBlankLines(editor: Editor): string {
content?: JsonNode[] const json = editor.getJSON()
text?: string if (!json.content) return ''
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
attrs?: Record<string, unknown>
}
// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text const blocks: string[] = []
function nodeToText(node: JsonNode): string {
if (!node.content) return '' // Helper to convert a node to markdown text
return node.content.map(child => { const nodeToText = (node: {
if (child.type === 'text') { type?: string
let text = child.text || '' content?: Array<{
if (child.marks) { type?: string
for (const mark of child.marks) { text?: string
if (mark.type === 'bold') text = `**${text}**` marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
else if (mark.type === 'italic') text = `*${text}*` attrs?: Record<string, unknown>
else if (mark.type === 'code') text = `\`${text}\`` }>
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` attrs?: Record<string, unknown>
}): string => {
if (!node.content) return ''
return node.content.map(child => {
if (child.type === 'text') {
let text = child.text || ''
// Apply marks (bold, italic, etc.)
if (child.marks) {
for (const mark of child.marks) {
if (mark.type === 'bold') text = `**${text}**`
else if (mark.type === 'italic') text = `*${text}*`
else if (mark.type === 'code') text = `\`${text}\``
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
}
} }
return text
} else if (child.type === 'wikiLink') {
const path = (child.attrs?.path as string) || ''
return path ? `[[${path}]]` : ''
} else if (child.type === 'hardBreak') {
return '\n'
} }
return text return ''
} else if (child.type === 'wikiLink') { }).join('')
const path = (child.attrs?.path as string) || '' }
const label = (child.attrs?.label as string | null | undefined) || ''
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
} else if (child.type === 'hardBreak') {
return '\n'
}
return ''
}).join('')
}
// Recursively serialize a list node (one line per item; nested lists indented two spaces) for (const node of json.content) {
function serializeList(listNode: JsonNode, indent: number): string[] { if (node.type === 'paragraph') {
const lines: string[] = []
const items = (listNode.content || []) as JsonNode[]
items.forEach((item, index) => {
const indentStr = ' '.repeat(indent)
let prefix: string
if (listNode.type === 'taskList') {
const checked = item.attrs?.checked ? 'x' : ' '
prefix = `- [${checked}] `
} else if (listNode.type === 'orderedList') {
prefix = `${index + 1}. `
} else {
prefix = '- '
}
const itemContent = (item.content || []) as JsonNode[]
let firstPara = true
itemContent.forEach(child => {
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
lines.push(...serializeList(child, indent + 1))
} else {
const text = nodeToText(child)
if (firstPara) {
lines.push(indentStr + prefix + text)
firstPara = false
} else {
lines.push(indentStr + ' ' + text)
}
}
})
})
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 {
switch (node.type) {
case 'paragraph': {
const text = nodeToText(node) const text = nodeToText(node)
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return '' // If the paragraph contains only the blank line marker or is empty, it's a blank line
return text if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) {
} // Push empty string to represent blank line - will add extra newline when joining
case 'heading': { blocks.push('')
} else {
blocks.push(text)
}
} else if (node.type === 'heading') {
const level = (node.attrs?.level as number) || 1 const level = (node.attrs?.level as number) || 1
return '#'.repeat(level) + ' ' + nodeToText(node) const text = nodeToText(node)
} blocks.push('#'.repeat(level) + ' ' + text)
case 'bulletList': } else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
case 'orderedList': // Recursively serialize lists to handle nested bullets
case 'taskList': const serializeList = (
return serializeList(node, 0).join('\n') listNode: { type?: string; content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> },
case 'taskBlock': indent: number
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' ): string[] => {
case 'promptBlock': const lines: string[] = []
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```' const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
case 'imageBlock': items.forEach((item, index) => {
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' const indentStr = ' '.repeat(indent)
case 'embedBlock': let prefix: string
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```' if (listNode.type === 'taskList') {
case 'iframeBlock': const checked = item.attrs?.checked ? 'x' : ' '
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```' prefix = `- [${checked}] `
case 'chartBlock': } else if (listNode.type === 'orderedList') {
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```' prefix = `${index + 1}. `
case 'tableBlock': } else {
return '```table\n' + (node.attrs?.data as string || '{}') + '\n```' prefix = '- '
case 'calendarBlock': }
return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```' const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
case 'emailBlock': let firstPara = true
return '```email\n' + (node.attrs?.data as string || '{}') + '\n```' itemContent.forEach(child => {
case 'transcriptBlock': if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```' lines.push(...serializeList(child, indent + 1))
case 'mermaidBlock': } else {
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```' const text = nodeToText(child)
case 'table': if (firstPara) {
return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim() lines.push(indentStr + prefix + text)
case 'codeBlock': { firstPara = false
} else {
lines.push(indentStr + ' ' + text)
}
}
})
})
return lines
}
blocks.push(serializeList(node, 0).join('\n'))
} else if (node.type === 'taskBlock') {
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'imageBlock') {
blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'embedBlock') {
blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'chartBlock') {
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'tableBlock') {
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'calendarBlock') {
blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'emailBlock') {
blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'transcriptBlock') {
blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'codeBlock') {
const lang = (node.attrs?.language as string) || '' const lang = (node.attrs?.language as string) || ''
return '```' + lang + '\n' + nodeToText(node) + '\n```' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
} } else if (node.type === 'blockquote') {
case 'blockquote': { const content = node.content || []
const content = (node.content || []) as JsonNode[] const quoteLines = content.map(para => '> ' + nodeToText(para))
return content.map(para => '> ' + nodeToText(para)).join('\n') blocks.push(quoteLines.join('\n'))
} } else if (node.type === 'horizontalRule') {
case 'horizontalRule': blocks.push('---')
return '---' } else if (node.type === 'wikiLink') {
case 'wikiLink': {
const path = (node.attrs?.path as string) || '' const path = (node.attrs?.path as string) || ''
const label = (node.attrs?.label as string | null | undefined) || '' blocks.push(`[[${path}]]`)
return `[[${path}${label ? `|${label}` : ''}]]` } else if (node.type === 'image') {
}
case 'image': {
const src = (node.attrs?.src as string) || '' const src = (node.attrs?.src as string) || ''
const alt = (node.attrs?.alt as string) || '' const alt = (node.attrs?.alt as string) || ''
return `![${alt}](${src})` blocks.push(`![${alt}](${src})`)
} }
default:
return ''
} }
}
// Pure helper: serialize a slice of top-level block nodes to markdown. // Custom join: content blocks get \n\n before them, empty blocks add \n each
// Custom join: content blocks get \n\n before them, empty blocks add \n each. // This produces: 1 empty paragraph = 3 newlines (1 blank line on disk)
// 1 empty paragraph = 3 newlines on disk (1 blank line).
function serializeBlocksToMarkdown(blocks: JsonNode[]): string {
if (blocks.length === 0) return '' if (blocks.length === 0) return ''
let result = '' let result = ''
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
const block = blockToMarkdown(blocks[i]) const block = blocks[i]
const isContent = block !== '' const isContent = block !== ''
if (i === 0) { if (i === 0) {
result = block result = block
} else if (isContent) { } else if (isContent) {
// Content block: add \n\n before it (standard paragraph break)
result += '\n\n' + block result += '\n\n' + block
} else { } else {
// Empty block: just add \n (one extra newline for blank line)
result += '\n' result += '\n'
} }
} }
return result return result
} }
import { EditorToolbar } from './editor-toolbar'
// Custom function to get markdown that preserves empty paragraphs as blank lines
function getMarkdownWithBlankLines(editor: Editor): string {
const json = editor.getJSON() as JsonNode
if (!json.content) return ''
return serializeBlocksToMarkdown(json.content as JsonNode[])
}
// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines
// would produce. Used to attach precise line-references when inserting editor-context mentions.
function getCursorContextLine(editor: Editor): number {
const $from = editor.state.selection.$from
const json = editor.getJSON() as JsonNode
const blocks = (json.content ?? []) as JsonNode[]
if (blocks.length === 0) return 1
const blockIndex = $from.index(0)
if (blockIndex < 0 || blockIndex >= blocks.length) return 1
// Line where the cursor's top-level block starts.
// Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line.
let blockStartLine: number
if (blockIndex === 0) {
blockStartLine = 1
} else {
const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex))
const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length
blockStartLine = prefixLineCount + 2
}
return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from)
}
// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading);
// for multi-line containers, computed against how the block serializes.
function computeWithinBlockOffset(
block: JsonNode,
$from: { parentOffset: number; depth: number; index: (depth: number) => number }
): number {
switch (block.type) {
case 'paragraph':
case 'heading': {
// Each hardBreak before the cursor moves us down one rendered line.
const offset = $from.parentOffset
let pos = 0
let hbCount = 0
for (const child of (block.content ?? [])) {
if (pos >= offset) break
const size = child.type === 'text' ? (child.text?.length ?? 0) : 1
if (child.type === 'hardBreak' && pos < offset) hbCount++
pos += size
}
return hbCount
}
case 'bulletList':
case 'orderedList':
case 'taskList':
case 'blockquote':
// Item index within the container = lines into the block (one item per line for shallow lists/quotes).
return $from.depth >= 1 ? $from.index(1) : 0
case 'codeBlock': {
// +1 for the opening ``` fence line, plus newlines within the code text before the cursor.
const text = block.content?.[0]?.text ?? ''
const before = text.substring(0, $from.parentOffset)
return 1 + (before.match(/\n/g)?.length ?? 0)
}
default:
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 { FrontmatterProperties } from './frontmatter-properties' import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link' import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' 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 { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover' import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css' import '@/styles/editor.css'
@ -525,112 +436,7 @@ const TabIndentExtension = Extension.create({
}, },
}) })
const slugifyHeading = (text: string) => export function MarkdownEditor({
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
}
export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
content, content,
onChange, onChange,
onPrimaryHeadingCommit, onPrimaryHeadingCommit,
@ -645,16 +451,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onFrontmatterChange, onFrontmatterChange,
onExport, onExport,
notePath, notePath,
}, ref) { }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false) const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) 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 [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null) const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null) const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
@ -677,7 +476,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Keep ref in sync with state for the plugin to access // Keep ref in sync with state for the plugin to access
selectionHighlightRef.current = selectionHighlight selectionHighlightRef.current = selectionHighlight
wikiLinksRef.current = wikiLinks
// Memoize the selection highlight extension // Memoize the selection highlight extension
const selectionHighlightExtension = useMemo( const selectionHighlightExtension = useMemo(
@ -754,7 +552,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
heading: { heading: {
levels: [1, 2, 3], levels: [1, 2, 3],
}, },
link: false,
}), }),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
@ -772,29 +569,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}), }),
ImageUploadPlaceholderExtension, ImageUploadPlaceholderExtension,
TaskBlockExtension, TaskBlockExtension,
PromptBlockExtension.configure({ notePath }),
ImageBlockExtension, ImageBlockExtension,
EmbedBlockExtension, EmbedBlockExtension,
IframeBlockExtension,
ChartBlockExtension, ChartBlockExtension,
TableBlockExtension, TableBlockExtension,
CalendarBlockExtension, CalendarBlockExtension,
EmailsBlockExtension,
EmailBlockExtension, EmailBlockExtension,
TranscriptBlockExtension, TranscriptBlockExtension,
MermaidBlockExtension,
WikiLink.configure({ WikiLink.configure({
onCreate: (path: string) => { onCreate: wikiLinks?.onCreate
void wikiLinksRef.current?.onCreate?.(path) ? (path) => {
}, void wikiLinks.onCreate(path)
}
: undefined,
}), }),
TaskList, TaskList,
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
TableKit.configure({
table: { resizable: false },
}),
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
}), }),
@ -913,57 +705,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
handleClickOn: (_view, _pos, node, _nodePos, event) => { handleClickOn: (_view, _pos, node, _nodePos, event) => {
if (node.type.name === 'wikiLink') { if (node.type.name === 'wikiLink') {
event.preventDefault() event.preventDefault()
const wikiPath = String(node.attrs.path ?? '') wikiLinks?.onOpen?.(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)
return true return true
} }
return false 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, editorSessionKey,
maybeCommitPrimaryHeading, maybeCommitPrimaryHeading,
notePath,
preventTitleHeadingDemotion, preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading, promoteFirstParagraphToTitleHeading,
]) ])
@ -1035,17 +785,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}) })
}, [editor, wikiLinks]) }, [editor, wikiLinks])
useImperativeHandle(ref, () => ({
getCursorContext: () => {
if (!notePath || !editor) return null
try {
return { path: notePath, lineNumber: getCursorContextLine(editor) }
} catch {
return null
}
},
}), [notePath, editor])
const updateRowboatMentionState = useCallback(() => { const updateRowboatMentionState = useCallback(() => {
if (!editor) return if (!editor) return
const { selection } = editor.state const { selection } = editor.state
@ -1211,37 +950,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Normalize for comparison (trim trailing whitespace from lines) // Normalize for comparison (trim trailing whitespace from lines)
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
// 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 isInternalUpdate.current = true
// Pre-process to preserve blank lines
const preprocessed = preprocessMarkdown(content) const preprocessed = preprocessMarkdown(content)
// Treat tab-open content as baseline: do not add hydration to undo history. // Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
// 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 isInternalUpdate.current = false
// Restore scroll last so it wins over any scrollIntoView triggered above.
if (wrapper) wrapper.scrollTop = prevScrollTop
} }
} }
}, [editor, content]) }, [editor, content])
@ -1601,26 +1315,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
return createImageUploadHandler(editor, onImageUpload) return createImageUploadHandler(editor, onImageUpload)
}, [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 ( return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}> <div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar <EditorToolbar
@ -1628,12 +1322,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onSelectionHighlight={setSelectionHighlight} onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder} onImageUpload={handleImageUploadWithPlaceholder}
onExport={onExport} onExport={onExport}
onOpenLiveNote={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
detail: { filePath: notePath },
}))
} : undefined}
liveState={notePath ? livePillStateForCurrentNote : undefined}
/> />
{(frontmatter !== undefined) && onFrontmatterChange && ( {(frontmatter !== undefined) && onFrontmatterChange && (
<FrontmatterProperties <FrontmatterProperties
@ -1760,4 +1448,4 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
</div> </div>
</div> </div>
) )
}) }

File diff suppressed because it is too large Load diff

View file

@ -1,89 +0,0 @@
import { useEffect, useId, useRef, useState } from 'react'
import mermaid from 'mermaid'
import { useTheme } from '@/contexts/theme-context'
let lastTheme: string | null = null
function ensureInit(theme: 'default' | 'dark') {
if (lastTheme === theme) return
mermaid.initialize({
startOnLoad: false,
theme,
securityLevel: 'strict',
})
lastTheme = theme
}
interface MermaidRendererProps {
source: string
className?: string
}
export function MermaidRenderer({ source, className }: MermaidRendererProps) {
const { resolvedTheme } = useTheme()
const id = useId().replace(/:/g, '-')
const containerRef = useRef<HTMLDivElement>(null)
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!source.trim()) {
setSvg(null)
setError(null)
return
}
let cancelled = false
const mermaidTheme = resolvedTheme === 'dark' ? 'dark' : 'default'
ensureInit(mermaidTheme)
mermaid
.render(`mermaid-${id}`, source.trim())
.then(({ svg: renderedSvg }) => {
if (!cancelled) {
setSvg(renderedSvg)
setError(null)
}
})
.catch((err: unknown) => {
if (!cancelled) {
setSvg(null)
setError(err instanceof Error ? err.message : 'Failed to render diagram')
}
})
return () => {
cancelled = true
}
}, [source, resolvedTheme, id])
if (error) {
return (
<div className={className}>
<div style={{ color: 'var(--destructive, #ef4444)', fontSize: 12, marginBottom: 4 }}>
Invalid mermaid syntax
</div>
<pre style={{ fontSize: 12, opacity: 0.7, whiteSpace: 'pre-wrap', margin: 0 }}>
<code>{source}</code>
</pre>
</div>
)
}
if (!svg) {
return (
<div className={className} style={{ fontSize: 13, opacity: 0.5 }}>
Rendering diagram...
</div>
)
}
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: svg }}
style={{ lineHeight: 0 }}
/>
)
}

View file

@ -59,14 +59,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null) 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 }>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
}) })
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle", status: "idle",
@ -96,20 +96,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null) const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Composio Gmail/Calendar sync was removed — flags are seeded false and // Composio/Gmail state
// never flipped. Kept here so legacy gating expressions still type-check. const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false) const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true) const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false) const [gmailConnecting, setGmailConnecting] = useState(false)
const [useComposioForGoogleCalendar] = useState(false) // Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback( 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 => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[provider]: { ...prev[provider], ...updates }, [provider]: { ...prev[provider], ...updates },
@ -151,8 +151,25 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
setProvidersLoading(false) 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() loadProviders()
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [open]) }, [open])
// Load LLM models catalog on open // Load LLM models catalog on open
@ -441,8 +458,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const baseURL = activeConfig.baseURL.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim() const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = { const providerConfig = {
provider: { provider: {
flavor: llmProvider, flavor: llmProvider,
@ -451,8 +466,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}, },
model, model,
knowledgeGraphModel, knowledgeGraphModel,
meetingNotesModel,
liveNoteAgentModel,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -605,20 +618,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Connect to a provider // Connect to a provider
const handleConnect = useCallback(async (provider: string) => { const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') { 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) setGoogleClientIdOpen(true)
return return
} }
await startConnect(provider) await startConnect(provider)
}, [startConnect, providerStates]) }, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret) setGoogleCredentials(clientId, clientSecret)
@ -1152,72 +1157,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</Select> </Select>
)} )}
</div> </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> </div>
{showApiKey && ( {showApiKey && (

View file

@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" /> <Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-foreground"> <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> </p>
<button <button
onClick={handleSwitchToRowboat} onClick={handleSwitchToRowboat}
@ -221,76 +221,6 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
</Select> </Select>
)} )}
</div> </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> </div>
{showApiKey && ( {showApiKey && (

View file

@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null) 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 }>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
}) })
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle", status: "idle",
@ -66,22 +66,22 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Inline upsell callout dismissed // Inline upsell callout dismissed
const [upsellDismissed, setUpsellDismissed] = useState(false) const [upsellDismissed, setUpsellDismissed] = useState(false)
// Composio Gmail/Calendar sync was removed — flags are seeded false and // Composio/Gmail state (used when signed in with Rowboat account)
// never flipped. Kept here so legacy gating expressions still type-check. const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false) const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true) const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false) const [gmailConnecting, setGmailConnecting] = useState(false)
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') 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 [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback( 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 => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[provider]: { ...prev[provider], ...updates }, [provider]: { ...prev[provider], ...updates },
@ -123,8 +123,25 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
setProvidersLoading(false) 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() loadProviders()
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [open]) }, [open])
// Load LLM models catalog on 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 baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim() const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = { const providerConfig = {
provider: { provider: {
flavor: llmProvider, flavor: llmProvider,
@ -428,8 +443,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
}, },
model, model,
knowledgeGraphModel, knowledgeGraphModel,
meetingNotesModel,
liveNoteAgentModel,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -446,7 +459,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
setTestState({ status: "error", error: "Connection test failed" }) setTestState({ status: "error", error: "Connection test failed" })
toast.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 // Check connection status for all providers
const refreshAllStatuses = useCallback(async () => { const refreshAllStatuses = useCallback(async () => {
@ -522,7 +535,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const cleanup = window.ipc.on('oauth:didConnect', async (event) => { const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
if (event.provider === 'rowboat' && event.success) { 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 setCurrentStep(2) // Go to Connect Accounts
} }
}) })
@ -582,20 +605,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Connect to a provider // Connect to a provider
const handleConnect = useCallback(async (provider: string) => { const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') { 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) setGoogleClientIdOpen(true)
return return
} }
await startConnect(provider) await startConnect(provider)
}, [startConnect, providerStates]) }, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret) 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,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback } from 'react'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import * as analytics from '@/lib/analytics' import * as analytics from '@/lib/analytics'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react' import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
@ -21,66 +21,36 @@ interface SearchResult {
path: string path: string
} }
export type SearchType = 'knowledge' | 'chat' type SearchType = 'knowledge' | 'chat'
function activeTabToTypes(section: ActiveSection): SearchType[] { function activeTabToTypes(section: ActiveSection): SearchType[] {
if (section === 'knowledge') return ['knowledge'] if (section === 'knowledge') return ['knowledge']
return ['chat'] return ['chat'] // "tasks" tab maps to chat
} }
// Retained for any remaining programmatic Copilot entry points (background-agent interface SearchDialogProps {
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
export type CommandPaletteContext = {
path: string
lineNumber: number
}
export type CommandPaletteMention = {
path: string
displayName: string
lineNumber?: number
}
interface CommandPaletteProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSelectFile: (path: string) => void onSelectFile: (path: string) => void
onSelectRun: (runId: 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
} }
export function CommandPalette({ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
open,
onOpenChange,
onSelectFile,
onSelectRun,
defaultScope,
}: CommandPaletteProps) {
const { activeSection } = useSidebarSection() const { activeSection } = useSidebarSection()
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([]) const [results, setResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>( const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
() => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)) () => new Set(activeTabToTypes(activeSection))
) )
const debouncedQuery = useDebounce(query, 250) const debouncedQuery = useDebounce(query, 250)
// Sync filters and clear query when the dialog opens. // Sync filter preselection when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setQuery('') setActiveTypes(new Set(activeTabToTypes(activeSection)))
setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)))
} }
}, [open, activeSection, defaultScope]) }, [open, activeSection])
useEffect(() => {
if (!open) return
searchInputRef.current?.focus()
}, [open])
const toggleType = useCallback((type: SearchType) => { const toggleType = useCallback((type: SearchType) => {
setActiveTypes(new Set([type])) setActiveTypes(new Set([type]))
@ -106,15 +76,20 @@ export function CommandPalette({
}) })
.catch((err) => { .catch((err) => {
console.error('Search failed:', err) console.error('Search failed:', err)
if (!cancelled) setResults([]) if (!cancelled) {
setResults([])
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setIsSearching(false) if (!cancelled) {
setIsSearching(false)
}
}) })
return () => { cancelled = true } return () => { cancelled = true }
}, [debouncedQuery, activeTypes]) }, [debouncedQuery, activeTypes])
// Reset state when dialog closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setQuery('') setQuery('')
@ -144,7 +119,6 @@ export function CommandPalette({
className="top-[20%] translate-y-0" className="top-[20%] translate-y-0"
> >
<CommandInput <CommandInput
ref={searchInputRef}
placeholder="Search..." placeholder="Search..."
value={query} value={query}
onValueChange={setQuery} onValueChange={setQuery}
@ -222,19 +196,17 @@ function FilterToggle({
}) { }) {
return ( return (
<button <button
type="button"
onClick={onClick} onClick={onClick}
className={cn( 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 active
? 'bg-accent text-accent-foreground' ? "bg-accent text-accent-foreground"
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50', : "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)} )}
> >
{icon} {icon}
<span>{label}</span> {label}
</button> </button>
) )
} }
// Back-compat export: thin alias to CommandPalette.
export const SearchDialog = CommandPalette

View file

@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useState, useEffect, useCallback, useMemo } 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 { import {
Dialog, Dialog,
@ -11,7 +11,6 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -25,9 +24,8 @@ import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner" import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings" import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-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 { interface TabConfig {
id: ConfigTab id: ConfigTab
@ -45,10 +43,10 @@ const tabs: TabConfig[] = [
description: "Manage your Rowboat account", description: "Manage your Rowboat account",
}, },
{ {
id: "connections", id: "connected-accounts",
label: "Connections", label: "Connected Accounts",
icon: Plug, icon: Plug,
description: "Manage accounts and tools", description: "Manage connected services",
}, },
{ {
id: "models", id: "models",
@ -71,18 +69,18 @@ const tabs: TabConfig[] = [
path: "config/security.json", path: "config/security.json",
description: "Configure allowed shell commands", description: "Configure allowed shell commands",
}, },
{
id: "code-mode",
label: "Code Mode",
icon: Terminal,
description: "Delegate coding tasks to Claude Code or Codex",
},
{ {
id: "appearance", id: "appearance",
label: "Appearance", label: "Appearance",
icon: Palette, icon: Palette,
description: "Customize the look and feel", description: "Customize the look and feel",
}, },
{
id: "tools",
label: "Tools Library",
icon: Wrench,
description: "Browse and enable toolkits",
},
{ {
id: "note-tagging", id: "note-tagging",
label: "Note Tagging", label: "Note Tagging",
@ -90,93 +88,10 @@ const tabs: TabConfig[] = [
path: "config/tags.json", path: "config/tags.json",
description: "Configure tags for notes and emails", description: "Configure tags for notes and emails",
}, },
{
id: "help",
label: "Help",
icon: HelpCircle,
description: "Get help and support",
},
] ]
interface SettingsDialogProps { interface SettingsDialogProps {
/** Optional trigger element. Omit when controlling `open` externally. */ children: React.ReactNode
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>
)
} }
// --- Theme option for Appearance tab --- // --- Theme option for Appearance tab ---
@ -211,7 +126,7 @@ function ThemeOption({
} }
function AppearanceSettings() { function AppearanceSettings() {
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme() const { theme, setTheme } = useTheme()
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -241,50 +156,6 @@ function AppearanceSettings() {
/> />
</div> </div>
</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> </div>
) )
} }
@ -322,27 +193,17 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
"openai-compatible": "http://localhost:1234/v1", "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 }) { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai") const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null) const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
}) })
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
@ -368,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0) (!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback( const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => { (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[prov]: { ...prev[prov], ...updates }, [prov]: { ...prev[prov], ...updates },
@ -441,9 +302,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels, models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "", 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] || ""), baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
models: activeModels.length > 0 ? activeModels : [""], models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "", knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
}; };
} }
return next; return next;
@ -536,9 +391,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
model: allModels[0] || "", model: allModels[0] || "",
models: allModels, models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, 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) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -571,9 +423,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
model: allModels[0], model: allModels[0],
models: allModels, models: allModels,
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}) })
setDefaultProvider(prov) setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed')) window.dispatchEvent(new Event('models-config-changed'))
@ -603,9 +452,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
parsed.model = defModels[0] || "" parsed.model = defModels[0] || ""
parsed.models = defModels parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined 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", { await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json", path: "config/models.json",
@ -613,7 +459,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}) })
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
})) }))
setTestState({ status: "idle" }) setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed')) window.dispatchEvent(new Event('models-config-changed'))
@ -803,108 +649,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</Select> </Select>
)} )}
</div> </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> </div>
{/* API Key */} {/* 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 --- // --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) { export function SettingsDialog({ children }: SettingsDialogProps) {
const [internalOpen, setInternalOpen] = useState(false) const [open, setOpen] = useState(false)
const open = controlledOpen ?? internalOpen const [activeTab, setActiveTab] = useState<ConfigTab>("account")
const setOpen = useCallback((next: boolean) => {
if (onOpenChange) onOpenChange(next)
else setInternalOpen(next)
}, [onOpenChange])
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("") const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false) 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 [error, setError] = useState<string | null>(null)
const [rowboatConnected, setRowboatConnected] = useState(false) 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 // Check if user is signed in to Rowboat
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -2034,7 +1529,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
} }
const loadConfig = useCallback(async (tab: ConfigTab) => { 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)! const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return if (!tabConfig.path) return
setLoading(true) setLoading(true)
@ -2100,7 +1595,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
{children && <DialogTrigger asChild>{children}</DialogTrigger>} <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent <DialogContent
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden" 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> </div>
{/* Content */} {/* 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" ? ( {activeTab === "account" ? (
<AccountSettings dialogOpen={open} /> <AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? ( ) : activeTab === "connected-accounts" ? (
<div className="space-y-6"> <ConnectedAccountsSettings dialogOpen={open} />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Primary accounts</h4>
<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" ? ( ) : activeTab === "models" ? (
rowboatConnected rowboatConnected
? <RowboatModelSettings dialogOpen={open} /> ? <RowboatModelSettings dialogOpen={open} />
@ -2165,10 +1650,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<NoteTaggingSettings dialogOpen={open} /> <NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? ( ) : activeTab === "appearance" ? (
<AppearanceSettings /> <AppearanceSettings />
) : activeTab === "help" ? ( ) : activeTab === "tools" ? (
<HelpSettings /> <ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
) : activeTab === "code-mode" ? (
<CodeModeSettings dialogOpen={open} />
) : loading ? ( ) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm"> <div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading... Loading...

View file

@ -17,44 +17,11 @@ import {
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling" import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner" import { toast } from "sonner"
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
interface AccountSettingsProps { interface AccountSettingsProps {
dialogOpen: boolean 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) { export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [isRowboatConnected, setIsRowboatConnected] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [connectionLoading, setConnectionLoading] = useState(true) const [connectionLoading, setConnectionLoading] = useState(true)
@ -62,7 +29,6 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [connecting, setConnecting] = useState(false) const [connecting, setConnecting] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null) const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected) const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
const checkConnection = useCallback(async () => { const checkConnection = useCallback(async () => {
try { try {
@ -197,7 +163,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium capitalize"> <p className="text-sm font-medium capitalize">
{formatPlanName(billing.subscriptionPlan)} {billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
</p> </p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => { {billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) 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> </div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}> <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> </Button>
</div> </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> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground">Unable to load plan details</p> <p className="text-xs text-muted-foreground">Unable to load plan details</p>
@ -245,15 +203,15 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!hasPaidSubscription} disabled={!billing?.subscriptionPlan}
onClick={() => appUrl && window.open(appUrl)} onClick={() => appUrl && window.open(appUrl)}
className="gap-1.5" className="gap-1.5"
> >
<ExternalLink className="size-3" /> <ExternalLink className="size-3" />
Manage in Stripe Manage in Stripe
</Button> </Button>
{!hasPaidSubscription && ( {!billing?.subscriptionPlan && (
<p className="text-[11px] text-muted-foreground">Upgrade to a paid plan first</p> <p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
)} )}
</div> </div>

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,246 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react'
import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js'
const SUGGESTED_TOPICS_PATH = 'suggested-topics.md'
const LEGACY_SUGGESTED_TOPICS_PATHS = [
'config/suggested-topics.md',
'knowledge/Notes/Suggested Topics.md',
]
/** Parse suggestedtopic code-fence blocks from the markdown file content. */
function parseTopics(content: string): SuggestedTopicBlock[] {
const topics: SuggestedTopicBlock[] = []
const regex = /```suggestedtopic\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1].trim())
const topic = SuggestedTopicBlockSchema.parse(parsed)
topics.push(topic)
} catch {
// Skip malformed blocks
}
}
if (topics.length > 0) return topics
const lines = content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
for (const line of lines) {
try {
const parsed = JSON.parse(line)
const topic = SuggestedTopicBlockSchema.parse(parsed)
topics.push(topic)
} catch {
// Skip malformed lines
}
}
return topics
}
function serializeTopics(topics: SuggestedTopicBlock[]): string {
const blocks = topics.map((topic) => [
'```suggestedtopic',
JSON.stringify(topic),
'```',
].join('\n'))
return ['# Suggested Topics', ...blocks].join('\n\n') + '\n'
}
const CATEGORY_COLORS: Record<string, string> = {
Meetings: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
Projects: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
People: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
Organizations: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400',
Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
}
function getCategoryColor(category?: string): string {
if (!category) return 'bg-muted text-muted-foreground'
return CATEGORY_COLORS[category] ?? 'bg-muted text-muted-foreground'
}
interface TopicCardProps {
topic: SuggestedTopicBlock
onTrack: () => void
isRemoving: boolean
}
function TopicCard({ topic, onTrack, isRemoving }: TopicCardProps) {
return (
<div className="group flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-5 transition-all hover:border-border hover:shadow-sm">
<div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-semibold leading-snug text-foreground">
{topic.title}
</h3>
{topic.category && (
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${getCategoryColor(topic.category)}`}
>
{topic.category}
</span>
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{topic.description}
</p>
<button
type="button"
onClick={onTrack}
disabled={isRemoving}
className="mt-auto flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{isRemoving ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Tracking
</>
) : (
<>
Track
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</>
)}
</button>
</div>
)
}
interface SuggestedTopicsViewProps {
onExploreTopic: (topic: SuggestedTopicBlock) => void
}
export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) {
const [topics, setTopics] = useState<SuggestedTopicBlock[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [removingIndex, setRemovingIndex] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
let result
try {
result = await window.ipc.invoke('workspace:readFile', {
path: SUGGESTED_TOPICS_PATH,
})
} catch {
let legacyResult: { data?: string } | null = null
let legacyPath: string | null = null
for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) {
try {
legacyResult = await window.ipc.invoke('workspace:readFile', { path })
legacyPath = path
break
} catch {
// Try next legacy location.
}
}
if (!legacyResult || !legacyPath || legacyResult.data === undefined) {
throw new Error('Suggested topics file not found')
}
await window.ipc.invoke('workspace:writeFile', {
path: SUGGESTED_TOPICS_PATH,
data: legacyResult.data,
opts: { encoding: 'utf8' },
})
await window.ipc.invoke('workspace:remove', {
path: legacyPath,
opts: { trash: true },
})
result = legacyResult
}
if (cancelled) return
if (result.data) {
setTopics(parseTopics(result.data))
}
} catch {
if (!cancelled) setError('No suggested topics yet. Check back once your knowledge graph has more data.')
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [])
const handleTrack = useCallback(
async (topic: SuggestedTopicBlock, topicIndex: number) => {
if (removingIndex !== null) return
const nextTopics = topics.filter((_, idx) => idx !== topicIndex)
setRemovingIndex(topicIndex)
setError(null)
try {
await window.ipc.invoke('workspace:writeFile', {
path: SUGGESTED_TOPICS_PATH,
data: serializeTopics(nextTopics),
opts: { encoding: 'utf8' },
})
setTopics(nextTopics)
} catch (err) {
console.error('Failed to remove suggested topic:', err)
setError('Failed to update suggested topics. Please try again.')
return
} finally {
setRemovingIndex(null)
}
onExploreTopic(topic)
},
[onExploreTopic, removingIndex, topics],
)
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (error || topics.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Lightbulb className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
{error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'}
</p>
</div>
)
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center gap-2">
<Lightbulb className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Suggested Topics</h2>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Suggested notes surfaced from your knowledge graph. Track one to start a tracking note.
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{topics.map((topic, i) => (
<TopicCard
key={`${topic.title}-${i}`}
topic={topic}
onTrack={() => { void handleTrack(topic, i) }}
isRemoving={removingIndex === i}
/>
))}
</div>
</div>
</div>
)
}

View file

@ -37,7 +37,7 @@ export function TabBar<T>({
return ( return (
<div <div
className={cn( className={cn(
'rowboat-tabbar flex flex-1 self-stretch min-w-0', 'flex flex-1 self-stretch min-w-0',
layout === 'scroll' layout === 'scroll'
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' ? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
: 'overflow-hidden' : 'overflow-hidden'
@ -57,7 +57,7 @@ export function TabBar<T>({
type="button" type="button"
onClick={() => onSwitchTab(tabId)} onClick={() => onSwitchTab(tabId)}
className={cn( 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]', layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
isActive isActive
? 'bg-background text-foreground' ? '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

@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( 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 className
)} )}
{...props} {...props}
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="sidebar-group" data-slot="sidebar-group"
data-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} {...props}
/> />
) )
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul <ul
data-slot="sidebar-menu" data-slot="sidebar-menu"
data-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} {...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" import * as React from "react"
export type Theme = "light" | "dark" | "system" export type Theme = "light" | "dark" | "system"
export type ChatPanePlacement = "right" | "middle"
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
type ThemeContextProps = { type ThemeContextProps = {
theme: Theme theme: Theme
resolvedTheme: "light" | "dark" resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
chatPanePlacement: ChatPanePlacement
setChatPanePlacement: (placement: ChatPanePlacement) => void
chatPaneSize: ChatPaneSize
setChatPaneSize: (size: ChatPaneSize) => void
} }
const ThemeContext = React.createContext<ThemeContextProps | null>(null) const ThemeContext = React.createContext<ThemeContextProps | null>(null)
const STORAGE_KEY = "rowboat-theme" 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" { function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light" if (typeof window === "undefined") return "light"
@ -55,16 +39,6 @@ export function ThemeProvider({
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
return stored || defaultTheme 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">(() => { const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
if (theme === "system") return getSystemTheme() if (theme === "system") return getSystemTheme()
@ -102,27 +76,13 @@ export function ThemeProvider({
setThemeState(newTheme) 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>( const contextValue = React.useMemo<ThemeContextProps>(
() => ({ () => ({
theme, theme,
resolvedTheme, resolvedTheme,
setTheme, setTheme,
chatPanePlacement,
setChatPanePlacement,
chatPaneSize,
setChatPaneSize,
}), }),
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize] [theme, resolvedTheme, setTheme]
) )
return ( return (

View file

@ -3,7 +3,6 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react' import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
import { blocks } from '@x/shared' import { blocks } from '@x/shared'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { extractConferenceLink } from '../lib/calendar-event'
function formatTime(dateStr: string): string { function formatTime(dateStr: string): string {
const d = new Date(dateStr) const d = new Date(dateStr)
@ -41,6 +40,25 @@ function getTimeRange(event: blocks.CalendarEvent): string {
return `${startTime} \u2013 ${endTime}` 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 { interface ResolvedEvent {
event: blocks.CalendarEvent event: blocks.CalendarEvent
loaded: blocks.CalendarEvent | null loaded: blocks.CalendarEvent | null

View file

@ -1,6 +1,6 @@
import { mergeAttributes, Node } from '@tiptap/react' import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } 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 { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context' import { useTheme } from '@/contexts/theme-context'
@ -11,47 +11,17 @@ function formatEmailDate(dateStr: string): string {
try { try {
const d = new Date(dateStr) const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr if (isNaN(d.getTime())) return dateStr
const now = new Date() return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() ' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
} catch { } catch {
return dateStr return dateStr
} }
} }
function formatFullDate(dateStr: string): string { /** Extract just the name part from "Name <email>" format */
try { function senderFirstName(from: string): string {
const d = new Date(dateStr) const name = from.replace(/<.*>/, '').trim()
if (isNaN(d.getTime())) return dateStr return name.split(/\s+/)[0] || name
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +
', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} catch {
return dateStr
}
}
function extractName(from: string): string {
const match = from.match(/^([^<]+)</)
if (match) return match[1].trim()
const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
return username.replace(/\b\w/g, c => c.toUpperCase())
}
function getInitial(from: string): string {
const name = extractName(from)
return (name[0] || '?').toUpperCase()
}
const GMAIL_AVATAR_COLORS = [
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
]
function avatarColor(from: string): string {
let hash = 0
for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
} }
declare global { declare global {
@ -60,307 +30,7 @@ declare global {
} }
} }
// --- Shared: expanded email body used by both block types --- // --- Email Block ---
function EmailExpandedBody({
config,
resolvedTheme,
}: {
config: blocks.EmailBlock
resolvedTheme: string
}) {
const [draftBody, setDraftBody] = useState(config.draft_response || '')
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setDraftBody(config.draft_response || '')
}, [config.draft_response])
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const draftWithAssistant = useCallback(() => {
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config, draftBody])
const copyDraft = useCallback(() => {
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
const el = document.createElement('textarea')
el.value = draftBody
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}, [draftBody])
const gmailUrl = config.threadId
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
const initial = config.from ? getInitial(config.from) : '?'
const color = config.from ? avatarColor(config.from) : '#5f6368'
const hasDraft = !!config.draft_response
return (
<div className="email-gmail-expanded">
{config.subject && (
<div className="email-gmail-exp-subject">{config.subject}</div>
)}
<div className="email-gmail-exp-meta">
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-gmail-exp-meta-right">
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
<div className="email-gmail-exp-to-date">
{config.to && <span>to {config.to}</span>}
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
</div>
</div>
</div>
<div className="email-gmail-exp-body">{config.latest_email}</div>
{config.past_summary && (
<div className="email-gmail-exp-history">
<div className="email-gmail-exp-history-label">Earlier conversation</div>
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
</div>
)}
{!hasDraft && (
<div className="email-gmail-reply-row">
{gmailUrl && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
<button
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
Draft with Rowboat
</button>
</div>
)}
{hasDraft && (
<div className="email-gmail-compose">
<div className="email-gmail-compose-to">
<span className="email-gmail-compose-to-label">Reply</span>
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-gmail-compose-body"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Write your reply..."
rows={3}
/>
<div className="email-gmail-compose-footer">
<button
className="email-gmail-btn email-gmail-btn-primary"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); copyDraft() }}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
{gmailUrl && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
</div>
</div>
)}
</div>
)
}
// --- Multi-email inbox block (language-emails) ---
function EmailsBlockView({ node, deleteNode }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
}) {
const raw = node.attrs.data as string
let config: blocks.EmailsBlock | null = null
try {
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
if (!config || config.emails.length === 0) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
{config.title && (
<div className="email-inbox-title">{config.title}</div>
)}
<div className="email-inbox-list">
{config.emails.map((email, i) => {
const isExpanded = expandedIndex === i
const senderName = email.from ? extractName(email.from) : 'Unknown'
const initial = email.from ? getInitial(email.from) : '?'
const color = email.from ? avatarColor(email.from) : '#5f6368'
const snippet = email.summary
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
return (
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
{/* Collapsed row */}
<div
className="email-inbox-row-header"
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-inbox-content">
<div className="email-inbox-top-row">
<span className="email-inbox-sender">{senderName}</span>
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
</div>
<div className="email-inbox-bottom-row">
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
{snippet && (
<span className="email-inbox-snippet">
{email.subject ? `${snippet}` : snippet}
</span>
)}
</div>
</div>
<ChevronDown
size={14}
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
/>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="email-inbox-expanded-wrap">
<EmailExpandedBody
config={email}
resolvedTheme={resolvedTheme}
/>
</div>
)}
</div>
)
})}
</div>
</div>
</NodeViewWrapper>
)
}
export const EmailsBlockExtension = Node.create({
name: 'emailsBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return { data: { default: '{}' } }
},
parseHTML() {
return [{
tag: 'pre',
priority: 61,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
if ((code.className || '').includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmailsBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```emails\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})
// --- Single email block (language-email, backward compat) ---
function EmailBlockView({ node, deleteNode, updateAttributes }: { function EmailBlockView({ node, deleteNode, updateAttributes }: {
node: { attrs: Record<string, unknown> } node: { attrs: Record<string, unknown> }
@ -372,57 +42,194 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
try { try {
config = blocks.EmailBlockSchema.parse(JSON.parse(raw)) 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 { 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) { if (!config) {
return ( return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block"> <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> </NodeViewWrapper>
) )
} }
const senderName = config.from ? extractName(config.from) : 'Unknown' const gmailUrl = config.threadId
const initial = config.from ? getInitial(config.from) : '?' ? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
const color = config.from ? avatarColor(config.from) : '#5f6368' : null
const snippet = config.summary
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '') // 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 ( return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block"> <NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}> <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 {/* Header: Email badge */}
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`} <div className="email-block-badge">
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }} <Mail size={13} />
onMouseDown={(e) => e.stopPropagation()} Email
>
<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> </div>
{expanded && ( {/* Summary */}
<EmailExpandedBody <div className="email-block-summary">{summary}</div>
config={config}
resolvedTheme={resolvedTheme} {/* Expandable email details */}
/> <button
className="email-block-expand-btn"
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{emailExpanded ? 'Hide email' : 'Show email'}
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
</button>
{emailExpanded && (
<div className="email-block-email-details">
<div className="email-block-message">
<div className="email-block-message-header">
<div className="email-block-sender-info">
<div className="email-block-sender-row">
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
</div>
</div>
<div className="email-block-message-body">{config.latest_email}</div>
</div>
{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> </div>
</NodeViewWrapper> </NodeViewWrapper>
) )
@ -436,7 +243,9 @@ export const EmailBlockExtension = Node.create({
draggable: false, draggable: false,
addAttributes() { addAttributes() {
return { data: { default: '{}' } } return {
data: { default: '{}' },
}
}, },
parseHTML() { parseHTML() {
@ -447,7 +256,7 @@ export const EmailBlockExtension = Node.create({
const code = element.querySelector('code') const code = element.querySelector('code')
if (!code) return false if (!code) return false
const cls = code.className || '' 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 { data: code.textContent || '{}' }
} }
return false return false

View file

@ -1,7 +1,6 @@
import { mergeAttributes, Node } from '@tiptap/react' import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, ExternalLink } from 'lucide-react' import { X, ExternalLink } from 'lucide-react'
import { Tweet } from 'react-tweet'
import { blocks } from '@x/shared' import { blocks } from '@x/shared'
function getEmbedUrl(provider: string, url: string): string | null { function getEmbedUrl(provider: string, url: string): string | null {
@ -25,28 +24,6 @@ function getEmbedUrl(provider: string, url: string): string | null {
return 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 }) { function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string const raw = node.attrs.data as string
let config: blocks.EmbedBlock | null = null 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) const embedUrl = getEmbedUrl(config.provider, config.url)
return ( return (
@ -81,14 +57,7 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
> >
<X size={14} /> <X size={14} />
</button> </button>
{config.provider === 'tweet' && tweetId ? ( {embedUrl ? (
<div
className="embed-block-tweet-shell"
onMouseDown={(event) => event.stopPropagation()}
>
<Tweet id={tweetId} />
</div>
) : embedUrl ? (
<div className="embed-block-iframe-container"> <div className="embed-block-iframe-container">
<iframe <iframe
src={embedUrl} 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,86 +0,0 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, GitBranch } from 'lucide-react'
import { MermaidRenderer } from '@/components/mermaid-renderer'
function MermaidBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const source = (node.attrs.data as string) || ''
return (
<NodeViewWrapper className="mermaid-block-wrapper" data-type="mermaid-block">
<div className="mermaid-block-card">
<button
className="mermaid-block-delete"
onClick={deleteNode}
aria-label="Delete mermaid block"
>
<X size={14} />
</button>
{source ? (
<MermaidRenderer source={source} />
) : (
<div className="mermaid-block-empty">
<GitBranch size={16} />
<span>Empty mermaid block</span>
</div>
)}
</div>
</NodeViewWrapper>
)
}
export const MermaidBlockExtension = Node.create({
name: 'mermaidBlock',
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-mermaid')) {
return { data: code.textContent || '' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(MermaidBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```mermaid\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

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

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

View file

@ -35,9 +35,8 @@ declare global {
}; };
electronUtils: { electronUtils: {
getPathForFile: (file: File) => string; getPathForFile: (file: File) => string;
getZoomFactor: () => number;
}; };
} }
} }
export { }; export { };

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,72 +0,0 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { LiveNoteAgentEvent } from '@x/shared/dist/live-note.js';
export type LiveNoteAgentStatus = 'idle' | 'running' | 'done' | 'error';
export interface LiveNoteAgentState {
status: LiveNoteAgentStatus;
runId?: string;
summary?: string | null;
error?: string | null;
}
// Module-level store — shared across all hook consumers, subscribed once.
// We replace the Map on every mutation so useSyncExternalStore detects the change.
let store = new Map<string, LiveNoteAgentState>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, LiveNoteAgentState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
}
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('live-note-agent:events', ((event: z.infer<typeof LiveNoteAgentEvent>) => {
const key = event.filePath;
if (event.type === 'live_note_agent_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'live_note_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 LiveNoteAgentEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
ensureSubscription();
listeners.add(onStoreChange);
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, LiveNoteAgentState> {
return store;
}
/**
* Returns a Map of all live-note agent run states, keyed by `filePath`.
*
* Usage in a panel:
* const status = useLiveNoteAgentStatus();
* const state = status.get(filePath) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const status = useLiveNoteAgentStatus();
* const anyRunning = [...status.values()].some(s => s.status === 'running');
*/
export function useLiveNoteAgentStatus(): Map<string, LiveNoteAgentState> {
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,6 +1,5 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
/** /**
* Identifies the user in PostHog when signed into Rowboat, * Identifies the user in PostHog when signed into Rowboat,
@ -18,7 +17,7 @@ export function useAnalyticsIdentity() {
// Identify if Rowboat account is connected // Identify if Rowboat account is connected
const rowboat = config.rowboat const rowboat = config.rowboat
if (rowboat?.connected && rowboat?.userId) { if (rowboat?.connected && rowboat?.userId) {
identifyUser(rowboat.userId) posthog.identify(rowboat.userId)
} }
// Set provider connection flags // Set provider connection flags
@ -59,29 +58,15 @@ export function useAnalyticsIdentity() {
// Listen for OAuth connect/disconnect events to update identity // Listen for OAuth connect/disconnect events to update identity
useEffect(() => { useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => { const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider !== 'rowboat') { if (!event.success) return
// Other providers: just toggle the connection flag
if (event.success) { // If Rowboat provider connected, identify user
posthog.people.set({ [`${event.provider}_connected`]: true }) if (event.provider === 'rowboat' && event.userId) {
} posthog.identify(event.userId)
return posthog.people.set({ signed_in: true })
} }
// Rowboat sign-in posthog.people.set({ [`${event.provider}_connected`]: true })
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 return cleanup

View file

@ -1,5 +1,14 @@
import { useState, useEffect, useCallback } from 'react' 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) { export function useBilling(isRowboatConnected: boolean) {
const [billing, setBilling] = useState<BillingInfo | null>(null) const [billing, setBilling] = useState<BillingInfo | null>(null)

View file

@ -38,21 +38,16 @@ export function useConnectors(active: boolean) {
const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null) const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Composio Gmail/Calendar sync was removed. These flags are seeded false // Composio/Gmail state
// and never flipped — the IPC that used to set them is gone. The setters const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
// remain so the legacy Composio-Gmail handlers below still type-check,
// but those handlers are no longer reachable in the UI (the gating
// condition `useComposioForGoogle` stays false).
// TODO follow-up: drop these flags entirely and prune the dead UI branches
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false) const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(false) const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false) const [gmailConnecting, setGmailConnecting] = useState(false)
const [useComposioForGoogleCalendar] = useState(false) // Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
// Load available providers on mount // Load available providers on mount
@ -72,7 +67,28 @@ export function useConnectors(active: boolean) {
loadProviders() 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 // Load Granola config
const refreshGranolaConfig = useCallback(async () => { const refreshGranolaConfig = useCallback(async () => {
@ -330,22 +346,13 @@ export function useConnectors(active: boolean) {
const handleConnect = useCallback(async (provider: string) => { const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') { 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) setGoogleClientIdDescription(undefined)
setGoogleClientIdOpen(true) setGoogleClientIdOpen(true)
return return
} }
await startConnect(provider) await startConnect(provider)
}, [startConnect, providerStates]) }, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret) setGoogleCredentials(clientId, clientSecret)
@ -354,25 +361,6 @@ export function useConnectors(active: boolean) {
startConnect('google', { clientId, clientSecret }) startConnect('google', { clientId, clientSecret })
}, [startConnect]) }, [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) => { const handleDisconnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({ setProviderStates(prev => ({
...prev, ...prev,
@ -497,6 +485,19 @@ export function useConnectors(active: boolean) {
toast.success(`Connected to ${displayName}`) 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() refreshAllStatuses()
} }
}) })
@ -553,7 +554,6 @@ export function useConnectors(active: boolean) {
providerStatus, providerStatus,
hasProviderError, hasProviderError,
handleConnect, handleConnect,
handleReconnect,
handleDisconnect, handleDisconnect,
startConnect, startConnect,

View file

@ -1,42 +1,5 @@
import posthog from 'posthog-js' 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) { export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId }) 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)
}

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