mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Compare commits
No commits in common. "main" and "v0.2.4" have entirely different histories.
253 changed files with 5288 additions and 39043 deletions
9
.github/workflows/electron-build.yml
vendored
9
.github/workflows/electron-build.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -111,7 +111,6 @@ jobs:
|
|||
with:
|
||||
name: distributables
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
|
|
@ -129,7 +128,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -176,7 +175,6 @@ jobs:
|
|||
with:
|
||||
name: distributables-linux
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
|
|
@ -194,7 +192,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -243,5 +241,4 @@ jobs:
|
|||
with:
|
||||
name: distributables-windows
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,4 +3,3 @@
|
|||
.vscode/
|
||||
data/
|
||||
.venv/
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -102,15 +102,6 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca
|
|||
| Workspace config | `apps/x/pnpm-workspace.yaml` |
|
||||
| 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
|
||||
|
||||
### LLM configuration (single provider)
|
||||
|
|
|
|||
11
apps/x/.claude/launch.json
Normal file
11
apps/x/.claude/launch.json
Normal 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
1
apps/x/.gitignore
vendored
|
|
@ -1,2 +1 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
@ -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:00–12:00 + a 12:00–15:00 window each get to fire even when the morning fire happens exactly at 12:00:00).
|
||||
- **Startup** — `initLiveNoteScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initLiveNoteEventProcessor()`.
|
||||
|
||||
### Event pipeline
|
||||
|
||||
**Producers** — any data source that should feed live notes emits events:
|
||||
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
|
||||
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`.
|
||||
|
||||
**Storage** — `packages/core/src/knowledge/live-note/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
|
||||
|
||||
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
|
||||
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
|
||||
2. `listEventEligibleLiveNotes()` scans every `.md` under `knowledge/`. Only notes where `live.active !== false` and `live.triggers?.eventMatchCriteria` is set are event-eligible.
|
||||
3. `findCandidates(event, eligible)` runs Pass 1 LLM routing (below).
|
||||
4. For each candidate, `runLiveNoteAgent(filePath, 'event', event.payload)` **sequentially** — preserves total ordering within the event.
|
||||
5. Enrich the event JSON with `processedAt`, `candidateFilePaths`, `runIds`, `error?`, then move to `events/done/<id>.json`.
|
||||
|
||||
**Pass 1 routing** (`routing.ts`):
|
||||
- **Short-circuit** — if `event.targetFilePath` is set (manual re-run events), skip the LLM and return that note directly.
|
||||
- Batches of `BATCH_SIZE = 20`.
|
||||
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `{ filePaths: string[] }`. Direct path-based dedup (no composite key needed since live-note is one-per-file).
|
||||
|
||||
**Pass 2 decision** happens inside the live-note agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body.
|
||||
|
||||
### Trigger granularity
|
||||
|
||||
Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | 'event'` — propagated end-to-end through `runLiveNoteAgent(filePath, trigger, context?)`, the `liveNoteBus` start event, and the `live-note-agent:events` IPC payload.
|
||||
|
||||
- The **scheduler** passes `'cron'` or `'window'` based on which sub-trigger `dueTimedTrigger` matched.
|
||||
- The **event processor** always passes `'event'`.
|
||||
- The **panel Run button** and the **`run-live-note-agent` builtin tool** both pass `'manual'`.
|
||||
|
||||
`buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context).
|
||||
|
||||
This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
|
||||
|
||||
### Run flow (`runLiveNoteAgent`)
|
||||
|
||||
Module: `packages/core/src/knowledge/live-note/runner.ts`.
|
||||
|
||||
1. **Concurrency guard** — static `runningLiveNotes: Set<string>` keyed by `filePath`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
|
||||
2. **Fetch live note** via `fetchLiveNote(filePath)`.
|
||||
3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff.
|
||||
4. **Create agent run** — `createRun({ agentId: 'live-note-agent' })`.
|
||||
5. **Bump `lastAttemptAt` + `lastRunId` immediately** (before the agent executes). `lastAttemptAt` is the disk-persistent backoff anchor — the scheduler suppresses fires within `RETRY_BACKOFF_MS` (5 min) of it, covering both in-flight runs and post-failure backoff. **`lastRunAt` is not touched here** — that field is the cycle anchor and should only move on success.
|
||||
6. **Emit `live_note_agent_start`** on the `liveNoteBus` with the trigger type (`manual` / `timed` / `event`).
|
||||
7. **Send agent message** built by `buildMessage(filePath, live, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly.
|
||||
8. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
|
||||
9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`.
|
||||
10. **On success:** `patchLiveNote(filePath, { lastRunAt: now, lastRunSummary, lastRunError: undefined })`.
|
||||
11. **On failure:** `patchLiveNote(filePath, { lastRunError: msg })`. **`lastRunAt` and `lastRunSummary` are deliberately untouched** so the user keeps seeing the last good state in the UI, and the scheduler treats the cycle as unfired (windows will retry inside the same band, gated only by the 5-min backoff).
|
||||
12. **Emit `live_note_agent_complete`** with `summary` or `error`.
|
||||
13. **Cleanup**: `runningLiveNotes.delete(filePath)` in a `finally` block.
|
||||
|
||||
Returned to callers: `{ filePath, runId, action, contentBefore, contentAfter, summary, error? }`.
|
||||
|
||||
**Stops** — when the user clicks Stop in the panel, `live-note:stop` resolves the latest `lastRunId` and calls `runsCore.stop(runId, false)`. The runner's `waitForRunCompletion` throws, the failure branch records `lastRunError`, and the bus emits `complete` with the error. The cycle stays unfired (so the run is retried on the next tick after backoff expires) — exactly the same path as any other failure.
|
||||
|
||||
### IPC surface
|
||||
|
||||
| Channel | Caller → handler | Purpose |
|
||||
|---|---|---|
|
||||
| `live-note:run` | Renderer (panel Run button) | Fires `runLiveNoteAgent(..., 'manual')` |
|
||||
| `live-note:get` | Panel on open | Returns the parsed `LiveNote \| null` from frontmatter |
|
||||
| `live-note:set` | Panel save | Validates + writes the whole `live:` block |
|
||||
| `live-note:setActive` | Panel toggle | Flips `active` |
|
||||
| `live-note:delete` | Panel "Make passive" | Removes the entire `live:` block |
|
||||
| `live-note:stop` | Panel Stop button | Resolves the live block's `lastRunId` and calls `runsCore.stop(runId)` |
|
||||
| `live-note:listNotes` | Background-agents view | Lists all live notes with summary fields |
|
||||
| `live-note-agent:events` | Server → renderer (`webContents.send`) | Forwards `liveNoteBus` events to `useLiveNoteAgentStatus` |
|
||||
|
||||
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/live-note/fileops.ts`.
|
||||
|
||||
### Concurrency & FIFO guarantees
|
||||
|
||||
- **Per-note serialization** — the `runningLiveNotes` guard in `runner.ts`. A note is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`.
|
||||
- **Backend is single writer for `live:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `live:` byte-for-byte across saves.
|
||||
- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file.
|
||||
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially.
|
||||
- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the note marked as ran; the scheduler's next tick computes the next occurrence from that point.
|
||||
|
||||
---
|
||||
|
||||
## Schema Reference
|
||||
|
||||
All canonical schemas live in `packages/shared/src/live-note.ts`:
|
||||
|
||||
- `LiveNoteSchema` — the entire `live:` block. Fields: `objective`, `active` (default true), `triggers?`, `model?`, `provider?`. **Runtime-managed (never hand-write):** `lastAttemptAt`, `lastRunAt`, `lastRunId`, `lastRunSummary`, `lastRunError`.
|
||||
- `TriggersSchema` — single object with three optional sub-fields: `cronExpr`, `windows`, `eventMatchCriteria`. Each window is `{ startTime, endTime }` (24-hour HH:MM, local).
|
||||
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidateFilePaths`, `runIds`, `error`) are populated when moving to `done/`.
|
||||
- `Pass1OutputSchema` — `{ filePaths: string[] }`.
|
||||
|
||||
The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` — so editing `LiveNoteSchema` propagates to the skill on the next build.
|
||||
|
||||
---
|
||||
|
||||
## Body Structure
|
||||
|
||||
The agent owns the entire body below the H1. There is **no formal section ownership** anymore — the agent edits, reorganizes, and dedupes freely.
|
||||
|
||||
The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/live-note/agent.ts`):
|
||||
|
||||
- **Defaults** (used when the objective doesn't specify a layout):
|
||||
- H1 stays the note title.
|
||||
- First, a 1-3 sentence rolling summary capturing the current state.
|
||||
- Then content organized by sub-topic under H2 headings, freshest/most-important first.
|
||||
- Tightness over decoration.
|
||||
- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly.
|
||||
- **Patch-style updates** — make small, incremental `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` |
|
||||
|
|
@ -10,13 +10,11 @@
|
|||
*/
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||
// The banner defines __import_meta_url at the top of the bundle,
|
||||
// and we use define to replace all import.meta.url references with it.
|
||||
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
||||
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['./dist/main.js'],
|
||||
|
|
@ -33,12 +31,6 @@ await esbuild.build({
|
|||
// Replace import.meta.url directly with our polyfill variable
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
// Inject PostHog credentials at build time. Reuse the renderer's
|
||||
// VITE_PUBLIC_* envs so packaging only needs one set of values.
|
||||
// Empty strings disable analytics gracefully.
|
||||
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
|
||||
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
|
||||
'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ module.exports = {
|
|||
icon: './icons/icon', // .icns extension added automatically
|
||||
appBundleId: 'com.rowboat.app',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
protocols: [
|
||||
{ name: 'Rowboat', schemes: ['rowboat'] },
|
||||
],
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
},
|
||||
|
|
@ -56,7 +53,6 @@ module.exports = {
|
|||
description: 'AI coworker with memory',
|
||||
name: `Rowboat-win32-${arch}`,
|
||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
@ -67,9 +63,7 @@ module.exports = {
|
|||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -80,9 +74,7 @@ module.exports = {
|
|||
name: `Rowboat-linux`,
|
||||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -13,8 +13,6 @@
|
|||
"make": "electron-forge make"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
|
|||
import { URL } from 'url';
|
||||
|
||||
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
||||
export const DEFAULT_PORT = 8080;
|
||||
export const PORT_RANGE_SIZE = 10;
|
||||
const DEFAULT_PORT = 8080;
|
||||
|
||||
/** Escape HTML special characters to prevent XSS */
|
||||
function escapeHtml(str: string): string {
|
||||
|
|
@ -20,8 +19,13 @@ export interface AuthServerResult {
|
|||
port: number;
|
||||
}
|
||||
|
||||
function tryBindPort(
|
||||
port: number,
|
||||
/**
|
||||
* Create a local HTTP server to handle OAuth callback
|
||||
* Listens on http://localhost:8080/oauth/callback
|
||||
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
||||
*/
|
||||
export function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -92,10 +96,8 @@ function tryBindPort(
|
|||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
server.close();
|
||||
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
||||
// Signal caller to try next port
|
||||
reject(Object.assign(new Error(err.code), { code: err.code }));
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
|
|
@ -103,51 +105,3 @@ function tryBindPort(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local HTTP server to handle OAuth callback.
|
||||
*
|
||||
* Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
|
||||
* thrown if it cannot be bound. This is the right behaviour for any provider
|
||||
* whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those
|
||||
* callers must keep using the exact port they've handed to the provider.
|
||||
*
|
||||
* Opt into `{ fallback: true }` only when the caller is prepared to use the
|
||||
* port returned in `AuthServerResult` (i.e. the redirect URI is built from the
|
||||
* actual bound port, not hard-coded). With fallback enabled, scans `port`
|
||||
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
||||
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
||||
* Hyper-V/WSL2 reserve the port).
|
||||
*/
|
||||
export async function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: { fallback?: boolean } = {},
|
||||
): Promise<AuthServerResult> {
|
||||
const fallback = opts.fallback === true;
|
||||
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
||||
|
||||
for (let p = port; p <= limit; p++) {
|
||||
try {
|
||||
return await tryBindPort(p, onCallback);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
||||
console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`);
|
||||
continue;
|
||||
}
|
||||
if (!fallback) {
|
||||
const reason = code === 'EACCES' || code === 'EADDRINUSE'
|
||||
? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
|
||||
: (err instanceof Error ? err.message : String(err));
|
||||
throw new Error(reason);
|
||||
}
|
||||
throw new Error(
|
||||
`No available port found in range ${port}–${limit}. Free a port in that range and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — loop always returns or throws — but satisfies TypeScript
|
||||
throw new Error(`No available port found in range ${port}–${limit}.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -44,7 +44,6 @@ export async function isConfigured(): Promise<{ configured: boolean }> {
|
|||
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
composioClient.setApiKey(apiKey);
|
||||
invalidateCopilotInstructionsCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
@ -293,6 +292,20 @@ export function listConnected(): { toolkits: string[] } {
|
|||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||
*/
|
||||
export async function useComposioForGoogle(): Promise<{ enabled: boolean }> {
|
||||
return { enabled: await composioClient.useComposioForGoogle() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google Calendar
|
||||
*/
|
||||
export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> {
|
||||
return { enabled: await composioClient.useComposioForGoogleCalendar() };
|
||||
}
|
||||
|
||||
/**
|
||||
* List available Composio toolkits — filtered to curated list only.
|
||||
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron';
|
||||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
listProviders,
|
||||
} from './oauth-handler.js';
|
||||
import { watcher as watcherCore, workspace } from '@x/core';
|
||||
import { WorkDir } from '@x/core/dist/config/config.js';
|
||||
import { workspace as workspaceShared } from '@x/shared';
|
||||
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
||||
import * as runsCore from '@x/core/dist/runs/runs.js';
|
||||
|
|
@ -31,16 +30,10 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
|||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import { consumePendingDeepLink } from './deeplink.js';
|
||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
|
|
@ -51,29 +44,6 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
import {
|
||||
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.
|
||||
|
|
@ -140,18 +110,6 @@ function markdownToHtml(markdown: string, title: string): string {
|
|||
</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 IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -313,7 +271,7 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
|
|||
|
||||
/**
|
||||
* 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).
|
||||
* 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();
|
||||
for (const win of windows) {
|
||||
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 {
|
||||
if (runsWatcher) {
|
||||
runsWatcher();
|
||||
|
|
@ -449,16 +381,6 @@ export function setupIpcHandlers() {
|
|||
// args is null for this channel (no request payload)
|
||||
return getVersions();
|
||||
},
|
||||
'app:consumePendingDeepLink': async () => {
|
||||
return { url: consumePendingDeepLink() };
|
||||
},
|
||||
'analytics:bootstrap': async () => {
|
||||
return {
|
||||
installationId: getInstallationId(),
|
||||
apiUrl: API_URL,
|
||||
appVersion: app.getVersion(),
|
||||
};
|
||||
},
|
||||
'workspace:getRoot': async () => {
|
||||
return workspace.getRoot();
|
||||
},
|
||||
|
|
@ -489,38 +411,6 @@ export function setupIpcHandlers() {
|
|||
'workspace:remove': async (_event, args) => {
|
||||
return workspace.remove(args.path, args.opts);
|
||||
},
|
||||
'gmail:getImportant': async (_event, args) => {
|
||||
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
|
||||
},
|
||||
'gmail:getEverythingElse': async (_event, args) => {
|
||||
return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit });
|
||||
},
|
||||
'gmail:triggerSync': async () => {
|
||||
triggerGmailSync();
|
||||
return {};
|
||||
},
|
||||
'gmail:sendReply': async (_event, args) => {
|
||||
return sendThreadReply(args);
|
||||
},
|
||||
'gmail:getConnectionStatus': async () => {
|
||||
return getGmailConnectionStatus();
|
||||
},
|
||||
'gmail:getAccountEmail': async () => {
|
||||
return { email: await getAccountEmail() };
|
||||
},
|
||||
'gmail:archiveThread': async (_event, args) => {
|
||||
return archiveThread(args.threadId);
|
||||
},
|
||||
'gmail:trashThread': async (_event, args) => {
|
||||
return trashThread(args.threadId);
|
||||
},
|
||||
'gmail:markThreadRead': async (_event, args) => {
|
||||
return markThreadRead(args.threadId);
|
||||
},
|
||||
'gmail:saveMessageHeight': async (_event, args) => {
|
||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
@ -531,17 +421,12 @@ export function setupIpcHandlers() {
|
|||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
return { success: true };
|
||||
},
|
||||
'codeRun:resolvePermission': async (_event, args) => {
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
registry.resolve(args.requestId, args.decision);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:provideHumanInput': async (_event, args) => {
|
||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||
return { success: true };
|
||||
|
|
@ -560,35 +445,6 @@ export function setupIpcHandlers() {
|
|||
await runsCore.deleteRun(args.runId);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:downloadLog': async (event, args) => {
|
||||
const runFileName = `${args.runId}.jsonl`;
|
||||
if (path.basename(runFileName) !== runFileName) {
|
||||
return { success: false, error: 'Invalid run id' };
|
||||
}
|
||||
|
||||
const sourcePath = path.join(WorkDir, 'runs', runFileName);
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${runFileName}.log`,
|
||||
filters: [
|
||||
{ name: 'Chat Log', extensions: ['log'] },
|
||||
{ name: 'JSONL', extensions: ['jsonl'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(sourcePath, result.filePath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'models:list': async () => {
|
||||
if (await isSignedIn()) {
|
||||
return await listGatewayModels();
|
||||
|
|
@ -640,20 +496,6 @@ export function setupIpcHandlers() {
|
|||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled };
|
||||
},
|
||||
'codeMode:getConfig': async () => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
|
||||
},
|
||||
'codeMode:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
|
||||
invalidateCopilotInstructionsCache();
|
||||
return { success: true };
|
||||
},
|
||||
'codeMode:checkAgentStatus': async () => {
|
||||
return await checkCodeModeAgentStatus();
|
||||
},
|
||||
'granola:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled });
|
||||
|
|
@ -724,8 +566,11 @@ export function setupIpcHandlers() {
|
|||
'composio:list-toolkits': async () => {
|
||||
return composioHandler.listToolkits();
|
||||
},
|
||||
'migration:check-composio-google': async () => {
|
||||
return qualifyAndDisconnectComposioGoogle();
|
||||
'composio:use-composio-for-google': async () => {
|
||||
return composioHandler.useComposioForGoogle();
|
||||
},
|
||||
'composio:use-composio-for-google-calendar': async () => {
|
||||
return composioHandler.useComposioForGoogleCalendar();
|
||||
},
|
||||
// Agent schedule handlers
|
||||
'agent-schedule:getConfig': async () => {
|
||||
|
|
@ -762,17 +607,24 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
// Shell integration handlers
|
||||
'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);
|
||||
return { error: error || undefined };
|
||||
},
|
||||
'shell:showItemInFolder': async (_event, args) => {
|
||||
const filePath = resolveShellPath(args.path);
|
||||
shell.showItemInFolder(filePath);
|
||||
return { success: true };
|
||||
},
|
||||
'shell:readFileBase64': async (_event, args) => {
|
||||
const filePath = resolveShellPath(args.path);
|
||||
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);
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
throw new Error('File too large (>10MB)');
|
||||
|
|
@ -791,19 +643,6 @@ export function setupIpcHandlers() {
|
|||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||
},
|
||||
'dialog:openDirectory': async (event, args) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir();
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
title: args.title ?? 'Choose work directory',
|
||||
defaultPath,
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { path: null };
|
||||
}
|
||||
return { path: result.filePaths[0] ?? null };
|
||||
},
|
||||
// Knowledge version history handlers
|
||||
'knowledge:history': async (_event, args) => {
|
||||
const commits = await versionHistory.getFileHistory(args.path);
|
||||
|
|
@ -919,140 +758,9 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
// Live-note handlers
|
||||
'live-note:run': async (_event, args) => {
|
||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||
return {
|
||||
success: !result.error,
|
||||
runId: result.runId,
|
||||
action: result.action,
|
||||
summary: result.summary,
|
||||
contentAfter: result.contentAfter,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
'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:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
// Embedded browser handlers (WebContentsView + navigation)
|
||||
...browserIpcHandlers,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
startBackgroundTaskAgentWatcher,
|
||||
startWorkspaceWatcher,
|
||||
stopRunsWatcher,
|
||||
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 initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
||||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
||||
import {
|
||||
DEEP_LINK_SCHEME,
|
||||
dispatchUrl,
|
||||
extractDeepLinkFromArgv,
|
||||
setMainWindowForDeepLinks,
|
||||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -62,44 +36,6 @@ const __dirname = dirname(__filename);
|
|||
// run this as early in the main process as possible
|
||||
if (started) app.quit();
|
||||
|
||||
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
|
||||
// back into the existing process via the 'second-instance' event.
|
||||
if (app.isPackaged && !app.requestSingleInstanceLock()) {
|
||||
console.error('[Main] Another Rowboat instance is already running; exiting this process.');
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Register as the OS handler for rowboat:// URLs.
|
||||
// In dev, point at the right argv so the OS can re-invoke us correctly.
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
|
||||
}
|
||||
|
||||
// First-launch URL on Windows/Linux comes through argv.
|
||||
{
|
||||
const initialUrl = extractDeepLinkFromArgv(process.argv);
|
||||
if (initialUrl) dispatchUrl(initialUrl);
|
||||
}
|
||||
|
||||
// macOS sends URLs via 'open-url' (both first launch and while running).
|
||||
app.on("open-url", (event, url) => {
|
||||
event.preventDefault();
|
||||
dispatchUrl(url);
|
||||
});
|
||||
|
||||
// Subsequent launches on Windows/Linux land here via the single-instance lock.
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
const url = extractDeepLinkFromArgv(argv);
|
||||
if (url) dispatchUrl(url);
|
||||
});
|
||||
|
||||
// Fix PATH for packaged Electron apps on macOS/Linux.
|
||||
// Packaged apps inherit a minimal environment that doesn't include paths from
|
||||
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
|
||||
|
|
@ -120,9 +56,7 @@ function initializeExecutionEnvironment(): void {
|
|||
).trim();
|
||||
|
||||
const env = JSON.parse(stdout) as Record<string, string>;
|
||||
// Let the user's shell environment win for overlapping keys like PATH.
|
||||
// Finder/launched GUI apps on macOS often start with a stripped PATH.
|
||||
process.env = { ...process.env, ...env };
|
||||
process.env = { ...env, ...process.env };
|
||||
} catch (error) {
|
||||
console.error('Failed to load shell environment', error);
|
||||
}
|
||||
|
|
@ -140,29 +74,16 @@ const rendererPath = app.isPackaged
|
|||
: path.join(__dirname, "../../../renderer/dist"); // Development
|
||||
console.log("rendererPath", rendererPath);
|
||||
|
||||
// Register custom protocol for serving built renderer files in production
|
||||
// AND for serving local workspace files to the renderer (images, PDFs, video).
|
||||
//
|
||||
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
|
||||
// app://<anything-else>/... → renderer SPA (existing behavior)
|
||||
// Register custom protocol for serving built renderer files in production.
|
||||
// This keeps SPA routes working when users deep link into the packaged app.
|
||||
function registerAppProtocol() {
|
||||
protocol.handle("app", (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Workspace files: app://workspace/<rel-path>
|
||||
if (url.host === "workspace") {
|
||||
try {
|
||||
const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||
if (!relPath) return new Response("Not Found", { status: 404 });
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
return net.fetch(pathToFileURL(absPath).toString());
|
||||
} catch {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer SPA — existing logic
|
||||
// url.pathname starts with "/"
|
||||
let urlPath = url.pathname;
|
||||
|
||||
// If it's "/" or a SPA route (no extension), serve index.html
|
||||
if (urlPath === "/" || !path.extname(urlPath)) {
|
||||
urlPath = "/index.html";
|
||||
}
|
||||
|
|
@ -181,36 +102,12 @@ protocol.registerSchemesAsPrivileged([
|
|||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
allowServiceWorkers: true,
|
||||
// Required for byte-range requests so <video> seeking works.
|
||||
stream: true,
|
||||
// optional but often helpful:
|
||||
// stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
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() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
|
|
@ -221,24 +118,35 @@ function createWindow() {
|
|||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: preloadPath,
|
||||
// Enable Chromium's built-in PDFium plugin so <iframe src="*.pdf">
|
||||
// renders PDFs natively (zoom/scroll/print toolbar included).
|
||||
plugins: true,
|
||||
},
|
||||
});
|
||||
|
||||
configureSessionPermissions(session.defaultSession);
|
||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||
// Grant microphone and display-capture permissions
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
if (permission === 'media' || permission === 'display-capture') {
|
||||
callback(true);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
|
||||
setMainWindowForDeepLinks(win);
|
||||
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||
// 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.
|
||||
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
|
||||
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) {
|
||||
win.loadURL("app://-/index.html");
|
||||
} else {
|
||||
|
|
@ -275,10 +179,10 @@ function createWindow() {
|
|||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register custom protocol before creating window.
|
||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||
// serves workspace files via app://workspace/<rel-path> for media previews.
|
||||
// Register custom protocol before creating window (for production builds)
|
||||
if (app.isPackaged) {
|
||||
registerAppProtocol();
|
||||
}
|
||||
|
||||
// Initialize auto-updater (only in production)
|
||||
if (app.isPackaged) {
|
||||
|
|
@ -307,18 +211,7 @@ app.whenReady().then(async () => {
|
|||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
||||
// PostHog identify() is idempotent — call it on every startup so existing
|
||||
// signed-in installs (and every cold start of v0.3.4+) get re-identified.
|
||||
// Otherwise main-process events stay anonymous until the user re-signs-in.
|
||||
identifyIfSignedIn().catch((error) => {
|
||||
console.error('[Analytics] Failed to identify on startup:', error);
|
||||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
||||
createWindow();
|
||||
|
||||
|
|
@ -335,30 +228,6 @@ app.whenReady().then(async () => {
|
|||
// start services watcher
|
||||
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
|
||||
initGmailSync();
|
||||
|
||||
|
|
@ -389,17 +258,9 @@ app.whenReady().then(async () => {
|
|||
// start agent notes learning service
|
||||
initAgentNotes();
|
||||
|
||||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
// start local sites server for iframe dashboards and other mini apps
|
||||
initLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to start:', error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
@ -418,16 +279,4 @@ app.on("before-quit", () => {
|
|||
stopWorkspaceWatcher();
|
||||
stopRunsWatcher();
|
||||
stopServicesWatcher();
|
||||
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
||||
try {
|
||||
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
||||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
shutdownAnalytics().catch((error) => {
|
||||
console.error('[Analytics] Failed to flush on quit:', error);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||
|
|
@ -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 triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
||||
import { emitOAuthEvent } from './ipc.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
|
||||
function buildRedirectUri(port: number): string {
|
||||
return `http://localhost:${port}/oauth/callback`;
|
||||
}
|
||||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||
|
||||
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
||||
|
|
@ -117,15 +109,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get or create OAuth configuration for a provider.
|
||||
* `redirectUri` is required for DCR providers — it is the actual callback URI
|
||||
* (including port) that was just bound, so the registration and auth URL stay in sync.
|
||||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(
|
||||
provider: string,
|
||||
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
|
||||
credentialsOverride?: { clientId: string; clientSecret: string },
|
||||
): Promise<Configuration> {
|
||||
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
||||
const config = await getProviderConfig(provider);
|
||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
|
|
@ -166,20 +152,17 @@ async function getProviderConfiguration(
|
|||
);
|
||||
}
|
||||
|
||||
// Register new client with the actual redirect URI (port already bound)
|
||||
// Register new client
|
||||
const scopes = config.scopes || [];
|
||||
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
||||
config.discovery.issuer,
|
||||
[redirectUri],
|
||||
[REDIRECT_URI],
|
||||
scopes
|
||||
);
|
||||
|
||||
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
|
||||
const boundPort = new URL(redirectUri).port
|
||||
? parseInt(new URL(redirectUri).port, 10)
|
||||
: DEFAULT_CALLBACK_PORT;
|
||||
await clientRepo.saveClientRegistration(provider, registration, boundPort);
|
||||
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
|
||||
// Save registration for future use
|
||||
await clientRepo.saveClientRegistration(provider, registration);
|
||||
console.log(`[OAuth] ${provider}: DCR registration saved`);
|
||||
|
||||
return oauthConfig;
|
||||
}
|
||||
|
|
@ -201,37 +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
|
||||
*/
|
||||
|
|
@ -247,45 +199,34 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
|
||||
if (provider === 'google') {
|
||||
if (!credentials?.clientId || !credentials?.clientSecret) {
|
||||
// No credentials → rowboat mode if the user is signed in to Rowboat
|
||||
// (we use the company-owned Google client via the api + webapp).
|
||||
// Otherwise it's BYOK with missing creds → error.
|
||||
if (await isSignedIn()) {
|
||||
try {
|
||||
const webappUrl = await getWebappUrl();
|
||||
await shell.openExternal(`${webappUrl}/oauth/google/start`);
|
||||
console.log('[OAuth] Started rowboat-mode Google connect (browser opened to webapp)');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Failed to start rowboat-mode Google connect:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to open browser',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'Google client ID and client secret are required to connect.' };
|
||||
}
|
||||
}
|
||||
|
||||
// For static-client providers (Google BYOK) the redirect URI is pre-registered
|
||||
// at the OAuth provider console on a fixed port — we must not scan.
|
||||
// For DCR providers, resolveStartPort handles the re-registration trap.
|
||||
const isStaticClient = providerConfig.client.mode === 'static';
|
||||
const startPort = isStaticClient
|
||||
? DEFAULT_CALLBACK_PORT
|
||||
: await resolveStartPort(provider, getClientRegistrationRepo());
|
||||
// Get or create OAuth configuration
|
||||
const config = await getProviderConfiguration(provider, credentials);
|
||||
|
||||
// --- Callback server ---
|
||||
// Declare `state` before the closure so the callback can close over its binding.
|
||||
// The variable is assigned below, before shell.openExternal, so it is always
|
||||
// set by the time any browser request arrives.
|
||||
let state = '';
|
||||
// Generate PKCE codes
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
const state = oauthClient.generateState();
|
||||
|
||||
// Get scopes from config
|
||||
const scopes = providerConfig.scopes || [];
|
||||
|
||||
// Store flow state
|
||||
activeFlows.set(state, { codeVerifier, provider, config });
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
});
|
||||
|
||||
// Create callback server
|
||||
let callbackHandled = false;
|
||||
|
||||
const { server, port: boundPort } = await createAuthServer(
|
||||
startPort,
|
||||
async (callbackUrl) => {
|
||||
const { server } = await createAuthServer(8080, async (callbackUrl) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
|
|
@ -314,15 +255,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
state
|
||||
);
|
||||
|
||||
// Save tokens and credentials. For Google, BYOK is the only path
|
||||
// that reaches this token exchange (rowboat path returns above
|
||||
// before any local server runs); stamp mode: 'byok' so a future
|
||||
// refresh / reconnect can't get confused with a rowboat entry.
|
||||
// Save tokens and credentials
|
||||
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||
await oauthRepo.upsert(provider, {
|
||||
tokens,
|
||||
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
||||
...(provider === 'google' ? { mode: 'byok' as const } : {}),
|
||||
error: null,
|
||||
});
|
||||
|
||||
|
|
@ -334,36 +271,8 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
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 } : {}),
|
||||
});
|
||||
emitOAuthEvent({ provider, success: true });
|
||||
} catch (error) {
|
||||
console.error('OAuth token exchange failed:', error);
|
||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
||||
|
|
@ -386,50 +295,18 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
activeFlow = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Static providers (Google BYOK) keep fixed-port behaviour to match the
|
||||
// pre-registered redirect URI at the provider's console. DCR providers
|
||||
// can fall back since we register the actual bound port below.
|
||||
{ fallback: !isStaticClient },
|
||||
);
|
||||
|
||||
// Server is bound. Any throw between here and `activeFlow = ...` would
|
||||
// leak the port — `cancelActiveFlow` only closes it once activeFlow is set.
|
||||
try {
|
||||
// TOCTOU guard: resolveStartPort probed the registered port and found it
|
||||
// free, but the port could have been grabbed between probe and real bind,
|
||||
// causing fallback to a different port. The cached client_id is registered
|
||||
// for the old port — clear it so getProviderConfiguration re-registers
|
||||
// with the actual bound port.
|
||||
if (!isStaticClient && boundPort !== startPort) {
|
||||
console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`);
|
||||
await getClientRegistrationRepo().clearClientRegistration(provider);
|
||||
}
|
||||
|
||||
const redirectUri = buildRedirectUri(boundPort);
|
||||
const config = await getProviderConfiguration(provider, redirectUri, credentials);
|
||||
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
state = oauthClient.generateState();
|
||||
|
||||
const scopes = providerConfig.scopes || [];
|
||||
activeFlows.set(state, { codeVerifier, provider, config });
|
||||
|
||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
});
|
||||
|
||||
// Set timeout to clean up abandoned flows (2 minutes)
|
||||
// This prevents memory leaks if user never completes the OAuth flow
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlow?.state === state) {
|
||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||
cancelActiveFlow('timed_out');
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
}, 2 * 60 * 1000); // 2 minutes
|
||||
|
||||
// Store complete flow state for cleanup
|
||||
activeFlow = {
|
||||
provider,
|
||||
state,
|
||||
|
|
@ -440,16 +317,8 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
// Open in system browser (shares cookies/sessions with user's regular browser)
|
||||
shell.openExternal(authUrl.toString());
|
||||
|
||||
// Wait for callback (server will handle it)
|
||||
return { success: true };
|
||||
} catch (setupError) {
|
||||
// Post-bind setup failed — close the server so the port is released and
|
||||
// a retry isn't blocked by our own zombie listener.
|
||||
server.close();
|
||||
if (state) {
|
||||
activeFlows.delete(state);
|
||||
}
|
||||
throw setupError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth connection failed:', error);
|
||||
return {
|
||||
|
|
@ -459,70 +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)
|
||||
*/
|
||||
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
|
||||
// For rowboat-mode Google, best-effort revoke at Google before clearing
|
||||
// local state. Google's revoke endpoint accepts an unauthenticated POST
|
||||
// with the access_token; failure is logged but doesn't block disconnect.
|
||||
if (provider === 'google') {
|
||||
const connection = await oauthRepo.read(provider);
|
||||
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
|
||||
try {
|
||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) {
|
||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[OAuth] Google revoke failed; continuing with local disconnect:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await oauthRepo.delete(provider);
|
||||
if (provider === 'rowboat') {
|
||||
analyticsCapture('user_signed_out');
|
||||
analyticsReset();
|
||||
}
|
||||
// Notify renderer so sidebar, voice, and billing re-check state
|
||||
emitOAuthEvent({ provider, success: false });
|
||||
return { success: true };
|
||||
|
|
@ -532,81 +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)
|
||||
* Refreshes token if expired
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
|
||||
async function main() {
|
||||
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',
|
||||
});
|
||||
console.log(`created run: ${id}`);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import { ipc as ipcShared } from '@x/shared';
|
||||
|
||||
type InvokeChannels = ipcShared.InvokeChannels;
|
||||
|
|
@ -55,5 +55,4 @@ contextBridge.exposeInMainWorld('ipc', ipc);
|
|||
|
||||
contextBridge.exposeInMainWorld('electronUtils', {
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
getZoomFactor: () => webFrame.getZoomFactor(),
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eigenpal/docx-editor-react": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
|
@ -26,16 +25,14 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "3.22.4",
|
||||
"@tiptap/extension-image": "3.22.4",
|
||||
"@tiptap/extension-link": "3.22.4",
|
||||
"@tiptap/extension-placeholder": "3.22.4",
|
||||
"@tiptap/extension-table": "3.22.4",
|
||||
"@tiptap/extension-task-item": "3.22.4",
|
||||
"@tiptap/extension-task-list": "3.22.4",
|
||||
"@tiptap/pm": "3.22.4",
|
||||
"@tiptap/react": "3.22.4",
|
||||
"@tiptap/starter-kit": "3.22.4",
|
||||
"@tiptap/extension-image": "^3.16.0",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/extension-task-item": "^3.15.3",
|
||||
"@tiptap/extension-task-list": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@x/preload": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"ai": "^5.0.117",
|
||||
|
|
@ -43,25 +40,13 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"motion": "^12.23.26",
|
||||
"nanoid": "^5.1.6",
|
||||
"posthog-js": "^1.332.0",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.7",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.41.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-tweet": "^3.2.2",
|
||||
"recharts": "^3.8.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.6.10",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
@ -69,7 +54,6 @@
|
|||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,6 @@ import { useState, useRef, useEffect } from "react";
|
|||
|
||||
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||
query: string;
|
||||
options?: string[];
|
||||
onResponse: (response: string) => void;
|
||||
isProcessing?: boolean;
|
||||
};
|
||||
|
|
@ -17,21 +16,17 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
|
|||
export const AskHumanRequest = ({
|
||||
className,
|
||||
query,
|
||||
options,
|
||||
onResponse,
|
||||
isProcessing = false,
|
||||
...props
|
||||
}: AskHumanRequestProps) => {
|
||||
const [response, setResponse] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const hasOptions = Array.isArray(options) && options.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
|
||||
if (!hasOptions) {
|
||||
// Auto-focus the textarea when component mounts
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [hasOptions]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = response.trim();
|
||||
|
|
@ -41,11 +36,6 @@ export const AskHumanRequest = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleOptionClick = (option: string) => {
|
||||
if (isProcessing) return;
|
||||
onResponse(option);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -75,22 +65,6 @@ export const AskHumanRequest = ({
|
|||
{query}
|
||||
</p>
|
||||
</div>
|
||||
{hasOptions ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options!.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOptionClick(option)}
|
||||
disabled={isProcessing}
|
||||
className="bg-background"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
|
|
@ -115,7 +89,6 @@ export const AskHumanRequest = ({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { isValidElement, type JSX } from 'react'
|
||||
import { FilePathCard } from './file-path-card'
|
||||
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
||||
|
||||
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||
const { children, ...rest } = props
|
||||
|
|
@ -20,17 +19,6 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -8,10 +9,9 @@ import {
|
|||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||
|
|
@ -22,15 +22,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
|
|||
onDeny?: () => void;
|
||||
isProcessing?: boolean;
|
||||
response?: 'approve' | 'deny' | null;
|
||||
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||
};
|
||||
|
||||
const fileActionLabels: Record<string, string> = {
|
||||
read: "Read file",
|
||||
list: "List folder",
|
||||
search: "Search files",
|
||||
write: "Write files",
|
||||
delete: "Delete path",
|
||||
};
|
||||
|
||||
export const PermissionRequest = ({
|
||||
|
|
@ -42,33 +33,26 @@ export const PermissionRequest = ({
|
|||
onDeny,
|
||||
isProcessing = false,
|
||||
response = null,
|
||||
permission,
|
||||
...props
|
||||
}: PermissionRequestProps) => {
|
||||
// Extract command from arguments if it's executeCommand
|
||||
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
||||
const command = toolCall.toolName === "executeCommand"
|
||||
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||
? String(toolCall.arguments.command)
|
||||
: JSON.stringify(toolCall.arguments))
|
||||
: null;
|
||||
const filePermission = permission?.kind === "file" ? permission : null;
|
||||
|
||||
const isResponded = response !== null;
|
||||
const isApproved = response === 'approve';
|
||||
|
||||
// Once a response is chosen, collapse the details to just the header.
|
||||
// Users can click the header to expand them again.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showDetails = !isResponded || expanded;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose mb-4 w-full rounded-md border",
|
||||
isResponded
|
||||
? isApproved
|
||||
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
|
||||
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
|
||||
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
|
||||
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
|
||||
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
className
|
||||
)}
|
||||
|
|
@ -76,14 +60,17 @@ export const PermissionRequest = ({
|
|||
>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{!isResponded && (
|
||||
{isResponded ? (
|
||||
isApproved ? (
|
||||
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
|
||||
)
|
||||
) : (
|
||||
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
|
||||
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-sm text-foreground">
|
||||
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
||||
|
|
@ -93,15 +80,30 @@ export const PermissionRequest = ({
|
|||
</p>
|
||||
</div>
|
||||
{isResponded && (
|
||||
<ChevronDownIcon
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded ? "rotate-180" : "rotate-0"
|
||||
"shrink-0",
|
||||
isApproved
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{isApproved ? (
|
||||
<>
|
||||
<CheckIcon className="size-3 mr-1" />
|
||||
Approved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon className="size-3 mr-1" />
|
||||
Denied
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{showDetails && command && (
|
||||
{command && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Command
|
||||
|
|
@ -111,35 +113,7 @@ export const PermissionRequest = ({
|
|||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && filePermission && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Action
|
||||
</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Path{filePermission.paths.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
||||
{filePermission.paths.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Approval Scope
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
||||
{filePermission.pathPrefix}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && !command && !filePermission && toolCall.arguments && (
|
||||
{!command && toolCall.arguments && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Arguments
|
||||
|
|
@ -159,12 +133,12 @@ export const PermissionRequest = ({
|
|||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={isProcessing}
|
||||
className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
|
||||
className={cn("flex-1", command && "rounded-r-none")}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
Approve
|
||||
</Button>
|
||||
{(command || filePermission) && (
|
||||
{command && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -91,12 +91,11 @@ export type FileMention = {
|
|||
id: string;
|
||||
path: string; // "knowledge/notes.md"
|
||||
displayName: string; // "notes"
|
||||
lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions)
|
||||
};
|
||||
|
||||
export type MentionsContext = {
|
||||
mentions: FileMention[];
|
||||
addMention: (path: string, displayName: string, lineNumber?: number) => void;
|
||||
addMention: (path: string, displayName: string) => void;
|
||||
removeMention: (id: string) => void;
|
||||
clearMentions: () => void;
|
||||
};
|
||||
|
|
@ -280,13 +279,13 @@ export function PromptInputProvider({
|
|||
// ----- mentions state (for @ file mentions)
|
||||
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
|
||||
|
||||
const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => {
|
||||
const addMention = useCallback((path: string, displayName: string) => {
|
||||
setMentionsList((prev) => {
|
||||
// Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct)
|
||||
if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) {
|
||||
// Avoid duplicates
|
||||
if (prev.some((m) => m.path === path)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, { id: nanoid(), path, displayName, lineNumber }];
|
||||
return [...prev, { id: nanoid(), path, displayName }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleCheck,
|
||||
LoaderIcon,
|
||||
ShieldCheckIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -51,68 +45,51 @@ const ToolCode = ({
|
|||
</pre>
|
||||
);
|
||||
|
||||
export type ToolAutoPermissionDetail = {
|
||||
decision: "allow";
|
||||
reason: string;
|
||||
};
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible> & {
|
||||
autoPermissionDetail?: ToolAutoPermissionDetail;
|
||||
};
|
||||
|
||||
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
|
||||
const toolCard = (
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
autoPermissionDetail
|
||||
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
|
||||
className
|
||||
)}
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
if (!autoPermissionDetail) return toolCard;
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-4 w-full">
|
||||
{toolCard}
|
||||
<div className="mt-1 flex justify-end px-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
|
||||
Auto-approved
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" className="max-w-sm">
|
||||
{autoPermissionDetail.reason}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
/** Hide the leading status icon (used for child rows inside a tool group). */
|
||||
hideLeadIcon?: boolean;
|
||||
};
|
||||
|
||||
// Lead icon shown to the left of the tool label: spinner while running, a
|
||||
// green check when done, a red cross on error. Shared by ToolHeader (single
|
||||
// tools) and the tool-call group.
|
||||
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => {
|
||||
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />;
|
||||
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
|
||||
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
|
|
@ -120,39 +97,32 @@ export const ToolHeader = ({
|
|||
title,
|
||||
type,
|
||||
state,
|
||||
hideLeadIcon,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
||||
|
||||
return (
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{!hideLeadIcon && getLeadIcon(state)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
};
|
||||
);
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
interface WebSearchResultProps {
|
||||
query: string;
|
||||
|
|
@ -21,220 +19,40 @@ interface WebSearchResultProps {
|
|||
title?: string;
|
||||
}
|
||||
|
||||
// How long each fetched website stays on the rolling header before the
|
||||
// next one slides in. Kept slow enough to read the domain + title.
|
||||
const ROLL_INTERVAL_MS = 700;
|
||||
|
||||
// How many favicons to show in the settled stack before the rest collapse
|
||||
// into a "+N" chip. The text names this many domains too, so the chip count
|
||||
// (total - MAX_STACK) lines up with the "and N others" in the summary.
|
||||
const MAX_STACK = 3;
|
||||
|
||||
function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function faviconUrl(domain: string, size = 32): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
|
||||
}
|
||||
|
||||
// Collapse the result list into unique domains, preserving order.
|
||||
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const result of results) {
|
||||
const domain = getDomain(result.url);
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
out.push(domain);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Summary with text hierarchy: "Searched" + "and N others" are secondary
|
||||
// weight/color, the domain names are primary text at medium weight.
|
||||
function buildSearchedSummary(domains: string[]): React.ReactNode {
|
||||
const muted = "font-normal text-muted-foreground";
|
||||
const name = (d: string) => <span className="font-medium text-foreground">{d}</span>;
|
||||
if (domains.length === 1) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (domains.length === 2) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}> and </span>
|
||||
{name(domains[1])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const others = domains.length - 2;
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}>, </span>
|
||||
{name(domains[1])}
|
||||
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type RollPhase = "searching" | "rolling" | "settled";
|
||||
|
||||
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
||||
const isRunning = status === "pending" || status === "running";
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const domains = useMemo(() => uniqueDomains(results), [results]);
|
||||
|
||||
// Drive the one-shot rolling reveal. Results arrive all at once, so we
|
||||
// simulate "fetching one site at a time" by stepping through them with the
|
||||
// same slide animation the tool group uses, then settle on a summary.
|
||||
// `settled` is seeded from the initial status so a card loaded already-
|
||||
// complete from history skips straight to the summary (no roll).
|
||||
const [settled, setSettled] = useState(() => !isRunning);
|
||||
const [rollIndex, setRollIndex] = useState(0);
|
||||
|
||||
// Phase is fully derived: searching while the tool runs, rolling once
|
||||
// results land, then settled. No setState-in-effect needed for transitions.
|
||||
const phase: RollPhase = isRunning
|
||||
? "searching"
|
||||
: !settled && results.length > 0
|
||||
? "rolling"
|
||||
: "settled";
|
||||
|
||||
// Warm the browser cache for every favicon the moment results arrive, so
|
||||
// each icon is already loaded by the time its row rolls in (~700ms each).
|
||||
// Without this the network fetch lags the text and rows flash icon-less.
|
||||
useEffect(() => {
|
||||
for (const result of results) {
|
||||
const img = new Image();
|
||||
img.src = faviconUrl(getDomain(result.url));
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
// Advance the roll, then settle after the last site has had its moment.
|
||||
// setState only fires inside the timeout callback, never synchronously.
|
||||
useEffect(() => {
|
||||
if (phase !== "rolling") return;
|
||||
const isLast = rollIndex >= results.length - 1;
|
||||
const timer = setTimeout(
|
||||
() => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)),
|
||||
ROLL_INTERVAL_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [phase, rollIndex, results.length]);
|
||||
|
||||
// Build the content for the compact (collapsed) header line. Each distinct
|
||||
// value gets a unique key so AnimatePresence runs the slide transition.
|
||||
let headerKey: string;
|
||||
let headerContent: React.ReactNode;
|
||||
if (phase === "searching") {
|
||||
headerKey = "searching";
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin" />
|
||||
<span className="truncate">Searching the web…</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"> · </span>
|
||||
<span>{result.title}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
headerKey = "settled";
|
||||
const stack = domains.slice(0, MAX_STACK);
|
||||
// Chip count matches the "and N others" in the text (total minus the 2
|
||||
// named domains), shown only when there are sites beyond the stack.
|
||||
const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0;
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{domains.length > 0 ? (
|
||||
<span className="flex shrink-0 items-center">
|
||||
{stack.map((domain, i) => (
|
||||
<img
|
||||
key={domain}
|
||||
src={faviconUrl(domain)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0"
|
||||
style={{ zIndex: stack.length - i }}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{domains.length > 0 ? buildSearchedSummary(domains) : title}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
||||
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={headerKey}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex items-center text-left font-medium text-sm"
|
||||
>
|
||||
{headerContent}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{phase === "settled" && domains.length > 0 && (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{domains.length} source{domains.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
{/* Query */}
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Query + result count */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{query}</span>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
{results.length > 0 && (
|
||||
|
|
@ -255,7 +73,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={faviconUrl(domain)}
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||
alt=""
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
|
|
@ -270,14 +88,21 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — only while the search is still running. */}
|
||||
{isRunning && (
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||
<span>Done</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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't supported.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/30 px-6">
|
||||
<FileAudioIcon className="size-10 text-muted-foreground" />
|
||||
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
|
||||
{basename(path)}
|
||||
</p>
|
||||
<audio
|
||||
key={path}
|
||||
src={src}
|
||||
controls
|
||||
className="w-full max-w-lg"
|
||||
onLoadedMetadata={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, FolderOpen, Pencil, Trash2 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
||||
|
|
@ -103,18 +103,9 @@ type BasesViewProps = {
|
|||
rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
}
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('mac')) return 'Finder'
|
||||
if (platform.includes('win')) return 'Explorer'
|
||||
return 'File Manager'
|
||||
}
|
||||
|
||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
return nodes.flatMap((n) =>
|
||||
n.kind === 'file' && n.name.endsWith('.md')
|
||||
|
|
@ -928,10 +919,6 @@ function NoteRow({
|
|||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions?.revealInFileManager(note.path, false)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {getFileManagerName()}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,19 +10,12 @@ import {
|
|||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FolderCheck,
|
||||
FolderClock,
|
||||
FolderCog,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Headphones,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Mic,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Terminal,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
|
|
@ -30,12 +23,8 @@ import { Button } from '@/components/ui/button'
|
|||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -67,12 +56,6 @@ export type StagedAttachment = {
|
|||
}
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
|
||||
const MAX_STORED_RECENT_WORK_DIRS = 8
|
||||
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
|
||||
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
|
||||
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
||||
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
||||
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
|
|
@ -86,27 +69,13 @@ const providerDisplayNames: Record<string, string> = {
|
|||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
|
||||
interface ConfiguredModel {
|
||||
provider: ProviderName
|
||||
flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
model: string
|
||||
}
|
||||
|
||||
type RecentWorkDir = {
|
||||
path: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type PermissionMode = 'manual' | 'auto'
|
||||
|
||||
function getSelectedModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
headers?: Record<string, string>
|
||||
knowledgeGraphModel?: string
|
||||
}
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
|
|
@ -128,86 +97,8 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
|
||||
if (typeof value === 'string') {
|
||||
const path = value.trim()
|
||||
return path ? { path, lastUsedAt: 0 } : null
|
||||
}
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const entry = value as Record<string, unknown>
|
||||
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
|
||||
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
|
||||
? entry.lastUsedAt
|
||||
: 0
|
||||
return path ? { path, lastUsedAt } : null
|
||||
}
|
||||
|
||||
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
|
||||
const parsed = JSON.parse(result.data)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const seen = new Set<string>()
|
||||
const dirs: RecentWorkDir[] = []
|
||||
for (const value of parsed) {
|
||||
const entry = normalizeRecentWorkDir(value)
|
||||
if (!entry || seen.has(entry.path)) continue
|
||||
seen.add(entry.path)
|
||||
dirs.push(entry)
|
||||
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
|
||||
}
|
||||
return dirs
|
||||
} catch {
|
||||
// File missing or invalid — no recents yet.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: RECENT_WORK_DIRS_CONFIG_PATH,
|
||||
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to persist recent work directories', err)
|
||||
}
|
||||
// Notify other mounted chat inputs in this window to re-read.
|
||||
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
function formatRecentWorkDirTime(lastUsedAt: number) {
|
||||
if (!lastUsedAt) return ''
|
||||
const now = Date.now()
|
||||
const diffMs = Math.max(0, now - lastUsedAt)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diffMs < minute) return 'now'
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
|
||||
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
|
||||
|
||||
const used = new Date(lastUsedAt)
|
||||
const yesterday = new Date(now - day)
|
||||
if (
|
||||
used.getFullYear() === yesterday.getFullYear() &&
|
||||
used.getMonth() === yesterday.getMonth() &&
|
||||
used.getDate() === yesterday.getDate()
|
||||
) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
if (diffMs < 7 * day) {
|
||||
return used.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
}
|
||||
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function compactWorkDirPath(path: string) {
|
||||
return path.replace(/^\/Users\/[^/]+/, '~')
|
||||
}
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
@ -229,12 +120,6 @@ interface ChatInputInnerProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
/** Work directory for this chat (per-chat). Null when none is set. */
|
||||
workDir?: string | null
|
||||
/** Fired when the user sets/changes/clears the work directory for this chat. */
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
|
|
@ -260,9 +145,6 @@ function ChatInputInner({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
workDir = null,
|
||||
onWorkDirChange,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -273,42 +155,9 @@ function ChatInputInner({
|
|||
|
||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
||||
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setLockedModel(null)
|
||||
setPermissionMode('auto')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
||||
if (cancelled) return
|
||||
if (run.provider && run.model) {
|
||||
setLockedModel({ provider: run.provider, model: run.model })
|
||||
}
|
||||
setPermissionMode(run.permissionMode ?? 'manual')
|
||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
|
||||
syncRecentWorkDirs()
|
||||
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||
return () => {
|
||||
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
useEffect(() => {
|
||||
|
|
@ -327,20 +176,42 @@ function ChatInputInner({
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Load the list of models the user can choose from.
|
||||
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
|
||||
// Load model config (gateway when signed in, local config when BYOK)
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
try {
|
||||
if (isRowboatConnected) {
|
||||
// Fetch gateway models
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = listResult.providers?.find(
|
||||
(p: { id: string }) => p.id === 'rowboat'
|
||||
)
|
||||
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
||||
(m: { id: string }) => ({ provider: 'rowboat', model: m.id })
|
||||
(m: { id: string }) => ({ flavor: 'rowboat', model: m.id })
|
||||
)
|
||||
|
||||
// Read current default from config
|
||||
let defaultModel = ''
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
defaultModel = parsed?.model || ''
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
if (defaultModel) {
|
||||
models.sort((a, b) => {
|
||||
if (a.model === defaultModel) return -1
|
||||
if (b.model === defaultModel) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
setConfiguredModels(models)
|
||||
const activeKey = defaultModel
|
||||
? `rowboat/${defaultModel}`
|
||||
: models[0] ? `rowboat/${models[0].model}` : ''
|
||||
if (activeKey) setActiveModelKey(activeKey)
|
||||
} else {
|
||||
// BYOK: read from local models.json
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ConfiguredModel[] = []
|
||||
|
|
@ -352,12 +223,32 @@ function ChatInputInner({
|
|||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({ provider: flavor as ProviderName, model })
|
||||
models.push({
|
||||
flavor: flavor as ConfiguredModel['flavor'],
|
||||
model,
|
||||
apiKey: (e.apiKey as string) || undefined,
|
||||
baseURL: (e.baseURL as string) || undefined,
|
||||
headers: (e.headers as Record<string, string>) || undefined,
|
||||
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||
? `${parsed.provider.flavor}/${parsed.model}`
|
||||
: ''
|
||||
models.sort((a, b) => {
|
||||
const aKey = `${a.flavor}/${a.model}`
|
||||
const bKey = `${b.flavor}/${b.model}`
|
||||
if (aKey === defaultKey) return -1
|
||||
if (bKey === defaultKey) return 1
|
||||
return 0
|
||||
})
|
||||
setConfiguredModels(models)
|
||||
if (defaultKey) {
|
||||
setActiveModelKey(defaultKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No config yet
|
||||
|
|
@ -375,147 +266,6 @@ function ChatInputInner({
|
|||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Load the global code-mode feature flag (from settings) and stay in sync.
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
window.ipc.invoke('codeMode:getConfig', null)
|
||||
.then((r) => setCodeModeFeatureEnabled(r.enabled))
|
||||
.catch(() => setCodeModeFeatureEnabled(false))
|
||||
}
|
||||
load()
|
||||
window.addEventListener('code-mode-config-changed', load)
|
||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||
}, [])
|
||||
|
||||
// If the feature is turned off in settings, also turn off any per-conversation chip.
|
||||
useEffect(() => {
|
||||
if (!codeModeFeatureEnabled && codeModeEnabled) {
|
||||
setCodeModeEnabled(false)
|
||||
}
|
||||
}, [codeModeFeatureEnabled, codeModeEnabled])
|
||||
|
||||
|
||||
// Cross-platform basename — handles both / and \ separators.
|
||||
const basename = useCallback((p: string): string => {
|
||||
const trimmed = p.replace(/[\\/]+$/, '')
|
||||
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
||||
}, [])
|
||||
|
||||
const rememberWorkDir = useCallback(async (dir: string) => {
|
||||
const trimmed = dir.trim()
|
||||
if (!trimmed) return
|
||||
const next = [
|
||||
{ path: trimmed, lastUsedAt: Date.now() },
|
||||
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
|
||||
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
|
||||
setRecentWorkDirs(next)
|
||||
await writeRecentWorkDirs(next)
|
||||
}, [])
|
||||
|
||||
// Load coding-agent preference for a given workdir.
|
||||
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
|
||||
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
|
||||
if (!dir) return 'claude'
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
||||
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
||||
const value = parsed?.[dir]
|
||||
if (value === 'codex' || value === 'claude') return value
|
||||
} catch {
|
||||
/* file missing or invalid — fall through to default */
|
||||
}
|
||||
return 'claude'
|
||||
}, [])
|
||||
|
||||
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
|
||||
const existing: Record<string, 'claude' | 'codex'> = {}
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
||||
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
||||
for (const [k, v] of Object.entries(parsed ?? {})) {
|
||||
if (v === 'claude' || v === 'codex') existing[k] = v
|
||||
}
|
||||
} catch { /* start fresh */ }
|
||||
existing[dir] = agent
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: 'config/coding-agents.json',
|
||||
data: JSON.stringify(existing, null, 2),
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Work directory is owned per-chat by the parent (App). This component only
|
||||
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
|
||||
// the work directory changes, load its persisted coding-agent preference.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadCodingAgentFor(workDir).then((agent) => {
|
||||
if (!cancelled) setCodingAgent(agent)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [workDir, loadCodingAgentFor])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && workDir) void rememberWorkDir(workDir)
|
||||
}, [isActive, workDir, rememberWorkDir])
|
||||
|
||||
const handleSetWorkDir = useCallback(async () => {
|
||||
try {
|
||||
let defaultPath: string | undefined = workDir ?? undefined
|
||||
try {
|
||||
const { root } = await window.ipc.invoke('workspace:getRoot', null)
|
||||
const workspaceRel = 'knowledge/Workspace'
|
||||
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
|
||||
if (!exists.exists) {
|
||||
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
|
||||
}
|
||||
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
|
||||
}
|
||||
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
|
||||
title: 'Choose work directory',
|
||||
defaultPath,
|
||||
})
|
||||
if (!chosen) return
|
||||
onWorkDirChange?.(chosen)
|
||||
await rememberWorkDir(chosen)
|
||||
setCodingAgent(await loadCodingAgentFor(chosen))
|
||||
toast.success(`Work directory set: ${chosen}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to set work directory', err)
|
||||
toast.error('Failed to set work directory')
|
||||
}
|
||||
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
|
||||
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
|
||||
onWorkDirChange?.(dir)
|
||||
await rememberWorkDir(dir)
|
||||
setCodingAgent(await loadCodingAgentFor(dir))
|
||||
toast.success(`Work directory set: ${dir}`)
|
||||
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
|
||||
const handleClearWorkDir = useCallback(() => {
|
||||
onWorkDirChange?.(null)
|
||||
setCodingAgent('claude')
|
||||
toast.success('Work directory cleared')
|
||||
}, [onWorkDirChange])
|
||||
|
||||
const handleToggleCodingAgent = useCallback(async () => {
|
||||
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
|
||||
setCodingAgent(next)
|
||||
// Persist only when scoped to a workdir; without one there's nothing to key on.
|
||||
if (!workDir) return
|
||||
try {
|
||||
await persistCodingAgent(workDir, next)
|
||||
} catch (err) {
|
||||
console.error('Failed to save coding agent', err)
|
||||
toast.error('Failed to save coding agent')
|
||||
// revert on failure
|
||||
setCodingAgent(codingAgent)
|
||||
}
|
||||
}, [workDir, codingAgent, persistCodingAgent])
|
||||
|
||||
// Check search tool availability (exa or signed-in via gateway)
|
||||
useEffect(() => {
|
||||
const checkSearch = async () => {
|
||||
|
|
@ -534,15 +284,40 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// Selecting a model affects only the *next* run created from this tab.
|
||||
// Once a run exists, model is frozen on the run and the dropdown is read-only.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
if (lockedModel) return
|
||||
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
|
||||
const handleModelChange = useCallback(async (key: string) => {
|
||||
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
|
||||
if (!entry) return
|
||||
setActiveModelKey(key)
|
||||
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
|
||||
}, [configuredModels, lockedModel, onSelectedModelChange])
|
||||
|
||||
try {
|
||||
if (entry.flavor === 'rowboat') {
|
||||
// Gateway model — save with valid Zod flavor, no credentials
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: { flavor: 'openrouter' as const },
|
||||
model: entry.model,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
} else {
|
||||
// BYOK — preserve full provider config
|
||||
const providerModels = configuredModels
|
||||
.filter((m) => m.flavor === entry.flavor)
|
||||
.map((m) => m.model)
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: {
|
||||
flavor: entry.flavor,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
},
|
||||
model: entry.model,
|
||||
models: providerModels,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to switch model')
|
||||
}
|
||||
}, [configuredModels])
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
|
|
@ -600,15 +375,12 @@ function ChatInputInner({
|
|||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit) return
|
||||
// codeMode is sticky per conversation — don't reset after send.
|
||||
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
|
||||
controller.textInput.clear()
|
||||
controller.mentions.clearMentions()
|
||||
setAttachments([])
|
||||
// Web search toggle stays on for the rest of the chat session; the user
|
||||
// turns it off explicitly. (Not persisted across app restarts.)
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
|
||||
setSearchEnabled(false)
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
|
|
@ -647,14 +419,8 @@ function ChatInputInner({
|
|||
}
|
||||
}, [addFiles, isActive])
|
||||
|
||||
const visibleRecentWorkDirs = recentWorkDirs
|
||||
.filter((entry) => entry.path !== workDir)
|
||||
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
|
||||
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
|
||||
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
||||
|
||||
return (
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
|
|
@ -758,246 +524,38 @@ function ChatInputInner({
|
|||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Add"
|
||||
aria-label="Attach files"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
|
||||
<div className="rounded-[14px] border border-border/80 bg-background p-1">
|
||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5">
|
||||
<ImagePlus className="size-4" />
|
||||
<span>Add files or photos</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Working directory lives behind a submenu so the main menu stays to two
|
||||
items. One hover/click away for power users; out of the way otherwise. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
|
||||
<FolderCog className="size-4" />
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>Set working directory</span>
|
||||
<span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground">
|
||||
{currentWorkDirLabel}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
|
||||
{/* Current selection — shown for context only when one is set. */}
|
||||
{workDir && (
|
||||
<div
|
||||
title={workDir}
|
||||
className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
|
||||
>
|
||||
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
|
||||
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
|
||||
{currentWorkDirPath}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary action: choose when unset, change when set. Always on top. */}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => { void handleSetWorkDir() }}
|
||||
className="h-9 rounded-[9px] px-2.5"
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
<span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{visibleRecentWorkDirs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Recent
|
||||
</div>
|
||||
{visibleRecentWorkDirs.map((entry) => {
|
||||
const name = basename(entry.path) || entry.path
|
||||
const when = formatRecentWorkDirTime(entry.lastUsedAt)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.path}
|
||||
title={entry.path}
|
||||
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
|
||||
className="h-8 rounded-[9px] px-2.5"
|
||||
>
|
||||
<FolderClock className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clear — only meaningful once a directory is set. Kept at the bottom. */}
|
||||
{workDir && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
<DropdownMenuItem
|
||||
onSelect={handleClearWorkDir}
|
||||
className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span>Clear folder</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{workDir && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{basename(workDir) || workDir}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWorkDir}
|
||||
aria-label="Remove work directory"
|
||||
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Work directory: {workDir}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{searchAvailable && (
|
||||
searchEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled((v) => !v)}
|
||||
aria-label="Search"
|
||||
aria-pressed={searchEnabled}
|
||||
className={cn(
|
||||
'flex h-7 shrink-0 items-center rounded-full border px-1.5 transition-colors duration-150 ease-out',
|
||||
searchEnabled
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setSearchEnabled(false)}
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out',
|
||||
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</span>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Search</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (runId) return
|
||||
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
|
||||
}}
|
||||
disabled={Boolean(runId)}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||
permissionMode === 'auto'
|
||||
? "bg-secondary text-foreground hover:bg-secondary/70"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
|
||||
)}
|
||||
aria-label="Permission mode"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{runId
|
||||
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
|
||||
: permissionMode === 'auto'
|
||||
? 'Auto-permission on — click for manual approval prompts'
|
||||
: 'Manual approval prompts — click for auto-permission'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCodeModeEnabled(false)}
|
||||
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Code mode on — click to disable</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-foreground/30">·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleCodingAgent}
|
||||
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCodeModeEnabled(true)}
|
||||
onClick={() => setSearchEnabled(true)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Code mode"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<Globe className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{lockedModel ? (
|
||||
<span
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
||||
>
|
||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
) : configuredModels.length > 0 ? (
|
||||
{configuredModels.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -1005,7 +563,7 @@ function ChatInputInner({
|
|||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1013,18 +571,18 @@ function ChatInputInner({
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
||||
{configuredModels.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
const key = `${m.flavor}/${m.model}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
)}
|
||||
{onToggleTts && ttsAvailable && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip>
|
||||
|
|
@ -1149,7 +707,7 @@ export interface ChatInputWithMentionsProps {
|
|||
knowledgeFiles: string[]
|
||||
recentFiles: string[]
|
||||
visibleFiles: string[]
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
@ -1171,9 +729,6 @@ export interface ChatInputWithMentionsProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
workDir?: string | null
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -1202,9 +757,6 @@ export function ChatInputWithMentions({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -1231,9 +783,6 @@ export function ChatInputWithMentions({
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
workDir={workDir}
|
||||
onWorkDirChange={onWorkDirChange}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChatHeader } from '@/components/chat-header'
|
||||
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import {
|
||||
|
|
@ -24,24 +16,19 @@ import {
|
|||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
|
||||
import { TerminalOutput } from '@/components/terminal-output'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import type { ChatPaneSize } from '@/contexts/theme-context'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
|
|
@ -51,47 +38,62 @@ import {
|
|||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { matchBillingError } from '@/lib/billing-error'
|
||||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
// Render user messages with markdown so bullets, bold, links, etc. survive the
|
||||
// round-trip from the input textarea. `remarkBreaks` turns single newlines
|
||||
// into <br> so typed line breaks are preserved without requiring blank lines.
|
||||
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
||||
/* ─── Billing error helpers ─── */
|
||||
|
||||
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
const ref = useRef<HTMLPreElement>(null)
|
||||
const stickToBottom = useRef(true)
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: 'You\'ve run out of credits',
|
||||
subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
pattern: /subscription not active/i,
|
||||
title: 'Your subscription is inactive',
|
||||
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||
cta: 'Reactivate',
|
||||
},
|
||||
] as const
|
||||
|
||||
function matchBillingError(message: string) {
|
||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||
}
|
||||
|
||||
function BillingErrorCTA({ label }: { label: string }) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (el && stickToBottom.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [children])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
stickToBottom.current = atBottom
|
||||
window.ipc.invoke('account:getRowboat', null)
|
||||
.then((account: any) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!appUrl) return null
|
||||
|
||||
return (
|
||||
<pre ref={ref} onScroll={handleScroll} className={className}>
|
||||
{children}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -126,16 +128,13 @@ interface ChatSidebarProps {
|
|||
defaultWidth?: number
|
||||
isOpen?: boolean
|
||||
isMaximized?: boolean
|
||||
placement?: 'middle' | 'right'
|
||||
paneSize?: ChatPaneSize
|
||||
className?: string
|
||||
chatTabs: ChatTab[]
|
||||
activeChatTabId: string
|
||||
getChatTabTitle: (tab: ChatTab) => string
|
||||
isChatTabProcessing: (tab: ChatTab) => boolean
|
||||
onSwitchChatTab: (tabId: string) => void
|
||||
onCloseChatTab: (tabId: string) => void
|
||||
onNewChatTab: () => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
onOpenFullScreen?: () => void
|
||||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
|
|
@ -144,7 +143,7 @@ interface ChatSidebarProps {
|
|||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
knowledgeFiles?: string[]
|
||||
recentFiles?: string[]
|
||||
visibleFiles?: string[]
|
||||
|
|
@ -153,20 +152,15 @@ interface ChatSidebarProps {
|
|||
onPresetMessageConsumed?: () => void
|
||||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
workDirByTab?: Record<string, string | null>
|
||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||
onOpenKnowledgeFile?: (path: string) => void
|
||||
onActivate?: () => void
|
||||
collapsedLeftPaddingPx?: number
|
||||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
|
|
@ -187,16 +181,13 @@ export function ChatSidebar({
|
|||
defaultWidth = DEFAULT_WIDTH,
|
||||
isOpen = true,
|
||||
isMaximized = false,
|
||||
placement = 'right',
|
||||
paneSize = 'chat-smaller',
|
||||
className,
|
||||
chatTabs,
|
||||
activeChatTabId,
|
||||
getChatTabTitle,
|
||||
isChatTabProcessing,
|
||||
onSwitchChatTab,
|
||||
onCloseChatTab,
|
||||
onNewChatTab,
|
||||
recentRuns = [],
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
onOpenFullScreen,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
|
|
@ -214,20 +205,15 @@ export function ChatSidebar({
|
|||
onPresetMessageConsumed,
|
||||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
workDirByTab = {},
|
||||
onWorkDirChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
autoPermissionDecisions = new Map(),
|
||||
onPermissionResponse,
|
||||
onAskHumanResponse,
|
||||
isToolOpenForTab,
|
||||
onToolOpenChangeForTab,
|
||||
onOpenKnowledgeFile,
|
||||
onActivate,
|
||||
collapsedLeftPaddingPx = 196,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
|
|
@ -242,7 +228,6 @@ export function ChatSidebar({
|
|||
onTtsModeChange,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [showContent, setShowContent] = useState(isOpen)
|
||||
|
|
@ -253,8 +238,6 @@ export function ChatSidebar({
|
|||
const startWidthRef = useRef(0)
|
||||
const prevIsMaximizedRef = useRef(isMaximized)
|
||||
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
||||
const isMiddlePlacement = placement === 'middle'
|
||||
const isResizable = paneSize === 'chat-smaller'
|
||||
|
||||
const getMaxAllowedWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') return MAX_WIDTH
|
||||
|
|
@ -315,9 +298,7 @@ export function ChatSidebar({
|
|||
setIsResizing(true)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const delta = isMiddlePlacement
|
||||
? event.clientX - startXRef.current
|
||||
: startXRef.current - event.clientX
|
||||
const delta = startXRef.current - event.clientX
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||
}
|
||||
|
|
@ -330,7 +311,7 @@ export function ChatSidebar({
|
|||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width, getMaxAllowedWidth, isMiddlePlacement])
|
||||
}, [width, getMaxAllowedWidth])
|
||||
|
||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: runId ?? null,
|
||||
|
|
@ -339,7 +320,6 @@ export function ChatSidebar({
|
|||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
}), [
|
||||
runId,
|
||||
conversation,
|
||||
|
|
@ -347,38 +327,15 @@ export function ChatSidebar({
|
|||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
])
|
||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||
if (tabId === activeChatTabId) return activeTabState
|
||||
return chatTabStates[tabId] ?? emptyTabState
|
||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||
const activeRunId = activeTabState.runId
|
||||
const handleDownloadChatLog = useCallback(async () => {
|
||||
if (!activeRunId) {
|
||||
toast.error('No chat log available yet')
|
||||
return
|
||||
}
|
||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
if (result.success) {
|
||||
toast.success('Chat log saved')
|
||||
} else if (result.error) {
|
||||
toast.error(result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Download chat log failed:', err)
|
||||
toast.error('Failed to download chat log')
|
||||
}
|
||||
}, [activeRunId])
|
||||
|
||||
const renderConversationItem = (
|
||||
item: ConversationItem,
|
||||
tabId: string,
|
||||
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||
) => {
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
|
|
@ -388,14 +345,7 @@ export function ChatSidebar({
|
|||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -416,12 +366,7 @@ export function ChatSidebar({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -471,25 +416,29 @@ export function ChatSidebar({
|
|||
key={item.id}
|
||||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||
autoPermissionDetail={options?.autoPermissionDetail}
|
||||
>
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
{item.streamingOutput ? (
|
||||
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||
<TerminalOutput raw={item.streamingOutput} />
|
||||
</AutoScrollPre>
|
||||
) : (
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
|
||||
if (isErrorMessage(item)) {
|
||||
if (matchBillingError(item.message)) {
|
||||
return null
|
||||
const billingError = matchBillingError(item.message)
|
||||
if (billingError) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
|
||||
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
|
||||
<BillingErrorCTA label={billingError.cta} />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
|
|
@ -512,32 +461,25 @@ export function ChatSidebar({
|
|||
// not add extra width to the right and overflow the app viewport.
|
||||
return { width: 0, flex: '1 1 auto' }
|
||||
}
|
||||
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
|
||||
return { width: 0, flex: '1 1 0' }
|
||||
}
|
||||
return { width, flex: '0 0 auto' }
|
||||
}, [isOpen, isMaximized, paneSize, width])
|
||||
}, [isOpen, isMaximized, width])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
data-chat-sidebar-root
|
||||
onMouseDownCapture={onActivate}
|
||||
onFocusCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col overflow-hidden bg-background',
|
||||
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
|
||||
className
|
||||
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{!isMaximized && isResizable && (
|
||||
{!isMaximized && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
|
||||
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
|
||||
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||
'hover:after:bg-sidebar-border',
|
||||
isResizing && 'after:bg-primary'
|
||||
|
|
@ -547,53 +489,29 @@ export function ChatSidebar({
|
|||
|
||||
{showContent && (
|
||||
<>
|
||||
<header
|
||||
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar"
|
||||
style={{
|
||||
paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined,
|
||||
paddingRight: isMaximized ? 12 : undefined,
|
||||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
<header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||
<TabBar
|
||||
tabs={chatTabs}
|
||||
activeTabId={activeChatTabId}
|
||||
getTabTitle={getChatTabTitle}
|
||||
getTabId={(tab) => tab.id}
|
||||
isProcessing={isChatTabProcessing}
|
||||
onSwitchTab={onSwitchChatTab}
|
||||
onCloseTab={onCloseChatTab}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNewChatTab}
|
||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Chat options"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Chat options</TooltipContent>
|
||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={!activeRunId}
|
||||
onSelect={() => {
|
||||
void handleDownloadChatLog()
|
||||
}}
|
||||
>
|
||||
<Bug className="size-4" />
|
||||
Download chat log
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onOpenFullScreen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -602,14 +520,14 @@ export function ChatSidebar({
|
|||
size="icon"
|
||||
onClick={onOpenFullScreen}
|
||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
|
||||
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
>
|
||||
{isMaximized
|
||||
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
|
||||
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
|
||||
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</header>
|
||||
|
|
@ -638,64 +556,24 @@ export function ChatSidebar({
|
|||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={cn(
|
||||
'mx-auto w-full max-w-4xl px-3',
|
||||
tabHasConversation ? 'pb-28' : 'pb-0',
|
||||
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
|
||||
)}>
|
||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
recentRuns={recentRuns}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
onPickPrompt={setLocalPresetMessage}
|
||||
/>
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{groupConversationItems(
|
||||
tabState.conversation,
|
||||
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||
).map((item) => {
|
||||
if (isToolGroup(item)) {
|
||||
return (
|
||||
<ToolGroupComponent
|
||||
key={item.groupId}
|
||||
group={item}
|
||||
isToolOpen={(toolId) => isToolOpenForTab?.(tab.id, toolId) ?? false}
|
||||
onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const autoDecision = isToolCall(item)
|
||||
? tabState.autoPermissionDecisions.get(item.id)
|
||||
: undefined
|
||||
const rendered = renderConversationItem(
|
||||
item,
|
||||
tab.id,
|
||||
autoDecision?.decision === 'allow'
|
||||
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
|
||||
: undefined,
|
||||
)
|
||||
if (isToolCall(item)) {
|
||||
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
||||
{tabState.conversation.map((item) => {
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{deniedAutoDecision && (
|
||||
<AutoPermissionDecision
|
||||
toolCall={deniedAutoDecision.toolCall}
|
||||
permission={deniedAutoDecision.permission}
|
||||
decision={deniedAutoDecision.decision}
|
||||
reason={deniedAutoDecision.reason}
|
||||
/>
|
||||
)}
|
||||
{permRequest && onPermissionResponse && (
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
permission={permRequest.permission}
|
||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
|
|
@ -703,8 +581,6 @@ export function ChatSidebar({
|
|||
isProcessing={isActive && isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
)}
|
||||
{rendered}
|
||||
</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="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="mx-auto w-full max-w-4xl px-3">
|
||||
{!hasConversation && (
|
||||
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
||||
)}
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getTabState(tab.id)
|
||||
|
|
@ -776,9 +655,6 @@ export function ChatSidebar({
|
|||
runId={tabState.runId}
|
||||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
workDir={workDirByTab[tab.id] ?? null}
|
||||
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'll do this later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onReconnect()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Reconnect Google
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,7 +79,16 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => c.handleReconnect(provider)}
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
c.setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
c.setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
c.startConnect(provider)
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Reconnect
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ import {
|
|||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -43,21 +42,6 @@ interface EditorToolbarProps {
|
|||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
onOpenLiveNote?: () => void
|
||||
liveState?: LivePillState
|
||||
}
|
||||
|
||||
export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error'
|
||||
export interface LivePillState {
|
||||
variant: LivePillVariant
|
||||
label: string
|
||||
}
|
||||
|
||||
const LIVE_PILL_VARIANT_CLASS: Record<LivePillVariant, string> = {
|
||||
passive: 'text-muted-foreground hover:bg-accent',
|
||||
idle: 'text-foreground hover:bg-accent',
|
||||
running: 'text-foreground bg-primary/10 hover:bg-primary/15 animate-pulse',
|
||||
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
|
|
@ -65,8 +49,6 @@ export function EditorToolbar({
|
|||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onExport,
|
||||
onOpenLiveNote,
|
||||
liveState,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -403,19 +385,6 @@ export function EditorToolbar({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live Note pill — pushed to far right */}
|
||||
{onOpenLiveNote && liveState && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLiveNote}
|
||||
title={liveState.variant === 'passive' ? 'Make this note live' : 'Live note'}
|
||||
className={`ml-auto inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors ${LIVE_PILL_VARIANT_CLASS[liveState.variant]}`}
|
||||
>
|
||||
<Radio className="size-3.5" />
|
||||
<span className="truncate max-w-[160px]">{liveState.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
|||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null {
|
||||
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||
const record: Record<string, string | string[]> = {}
|
||||
for (const { key, value } of fields) {
|
||||
if (key.trim()) record[key.trim()] = value
|
||||
}
|
||||
return buildFrontmatter(record, preserveRaw)
|
||||
return buildFrontmatter(record)
|
||||
}
|
||||
|
||||
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||
|
|
@ -45,12 +45,10 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
|
|||
}, [editingNewKey])
|
||||
|
||||
const commit = useCallback((updated: FieldEntry[]) => {
|
||||
// Use the latest raw seen as the preserve-source so structured keys
|
||||
// (like `live:`) survive a round-trip through this UI.
|
||||
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
|
||||
const newRaw = fieldsToRaw(updated)
|
||||
lastCommittedRaw.current = newRaw
|
||||
onRawChange(newRaw)
|
||||
}, [onRawChange, raw])
|
||||
}, [onRawChange])
|
||||
|
||||
// For scalar fields: update local state immediately, commit on blur
|
||||
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||
|
|
|
|||
100
apps/x/apps/renderer/src/components/help-popover.tsx
Normal file
100
apps/x/apps/renderer/src/components/help-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 & 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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
|
||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -7,22 +7,17 @@ import Image from '@tiptap/extension-image'
|
|||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
|
||||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { PromptBlockExtension } from '@/extensions/prompt-block'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block'
|
||||
import { EmailBlockExtension } from '@/extensions/email-block'
|
||||
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
|
||||
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
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'
|
||||
|
||||
// Zero-width space used as invisible marker for blank lines
|
||||
|
|
@ -58,20 +53,29 @@ function postprocessMarkdown(markdown: string): string {
|
|||
}).join('\n')
|
||||
}
|
||||
|
||||
type JsonNode = {
|
||||
// Custom function to get markdown that preserves empty paragraphs as blank lines
|
||||
function getMarkdownWithBlankLines(editor: Editor): string {
|
||||
const json = editor.getJSON()
|
||||
if (!json.content) return ''
|
||||
|
||||
const blocks: string[] = []
|
||||
|
||||
// Helper to convert a node to markdown text
|
||||
const nodeToText = (node: {
|
||||
type?: string
|
||||
content?: Array<{
|
||||
type?: string
|
||||
content?: JsonNode[]
|
||||
text?: string
|
||||
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
|
||||
function nodeToText(node: JsonNode): string {
|
||||
}>
|
||||
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}**`
|
||||
|
|
@ -83,19 +87,36 @@ function nodeToText(node: JsonNode): string {
|
|||
return text
|
||||
} else if (child.type === 'wikiLink') {
|
||||
const path = (child.attrs?.path as string) || ''
|
||||
const label = (child.attrs?.label as string | null | undefined) || ''
|
||||
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
|
||||
return path ? `[[${path}]]` : ''
|
||||
} else if (child.type === 'hardBreak') {
|
||||
return '\n'
|
||||
}
|
||||
return ''
|
||||
}).join('')
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively serialize a list node (one line per item; nested lists indented two spaces)
|
||||
function serializeList(listNode: JsonNode, indent: number): string[] {
|
||||
for (const node of json.content) {
|
||||
if (node.type === 'paragraph') {
|
||||
const text = nodeToText(node)
|
||||
// If the paragraph contains only the blank line marker or is empty, it's a blank line
|
||||
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) {
|
||||
// Push empty string to represent blank line - will add extra newline when joining
|
||||
blocks.push('')
|
||||
} else {
|
||||
blocks.push(text)
|
||||
}
|
||||
} else if (node.type === 'heading') {
|
||||
const level = (node.attrs?.level as number) || 1
|
||||
const text = nodeToText(node)
|
||||
blocks.push('#'.repeat(level) + ' ' + text)
|
||||
} else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
|
||||
// Recursively serialize lists to handle nested bullets
|
||||
const serializeList = (
|
||||
listNode: { type?: string; content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> },
|
||||
indent: number
|
||||
): string[] => {
|
||||
const lines: string[] = []
|
||||
const items = (listNode.content || []) as JsonNode[]
|
||||
const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
|
||||
items.forEach((item, index) => {
|
||||
const indentStr = ' '.repeat(indent)
|
||||
let prefix: string
|
||||
|
|
@ -107,7 +128,7 @@ function serializeList(listNode: JsonNode, indent: number): string[] {
|
|||
} else {
|
||||
prefix = '- '
|
||||
}
|
||||
const itemContent = (item.content || []) as JsonNode[]
|
||||
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> }>
|
||||
let firstPara = true
|
||||
itemContent.forEach(child => {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
|
||||
|
|
@ -124,182 +145,72 @@ function serializeList(listNode: JsonNode, indent: number): string[] {
|
|||
})
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is
|
||||
// actually invoked — the other helpers are stubs to satisfy the type.
|
||||
const tableRenderHelpers: MarkdownRendererHelpers = {
|
||||
renderChildren: (nodes) => {
|
||||
const arr = Array.isArray(nodes) ? nodes : [nodes]
|
||||
return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('')
|
||||
},
|
||||
wrapInBlock: (prefix, content) => prefix + content,
|
||||
indent: (content) => content,
|
||||
}
|
||||
|
||||
// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker
|
||||
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
|
||||
function blockToMarkdown(node: JsonNode): string {
|
||||
switch (node.type) {
|
||||
case 'paragraph': {
|
||||
const text = nodeToText(node)
|
||||
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return ''
|
||||
return text
|
||||
}
|
||||
case 'heading': {
|
||||
const level = (node.attrs?.level as number) || 1
|
||||
return '#'.repeat(level) + ' ' + nodeToText(node)
|
||||
}
|
||||
case 'bulletList':
|
||||
case 'orderedList':
|
||||
case 'taskList':
|
||||
return serializeList(node, 0).join('\n')
|
||||
case 'taskBlock':
|
||||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'promptBlock':
|
||||
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'iframeBlock':
|
||||
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'chartBlock':
|
||||
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'tableBlock':
|
||||
return '```table\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'calendarBlock':
|
||||
return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'emailBlock':
|
||||
return '```email\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'transcriptBlock':
|
||||
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'mermaidBlock':
|
||||
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'table':
|
||||
return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim()
|
||||
case 'codeBlock': {
|
||||
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) || ''
|
||||
return '```' + lang + '\n' + nodeToText(node) + '\n```'
|
||||
}
|
||||
case 'blockquote': {
|
||||
const content = (node.content || []) as JsonNode[]
|
||||
return content.map(para => '> ' + nodeToText(para)).join('\n')
|
||||
}
|
||||
case 'horizontalRule':
|
||||
return '---'
|
||||
case 'wikiLink': {
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
} else if (node.type === 'blockquote') {
|
||||
const content = node.content || []
|
||||
const quoteLines = content.map(para => '> ' + nodeToText(para))
|
||||
blocks.push(quoteLines.join('\n'))
|
||||
} else if (node.type === 'horizontalRule') {
|
||||
blocks.push('---')
|
||||
} else if (node.type === 'wikiLink') {
|
||||
const path = (node.attrs?.path as string) || ''
|
||||
const label = (node.attrs?.label as string | null | undefined) || ''
|
||||
return `[[${path}${label ? `|${label}` : ''}]]`
|
||||
}
|
||||
case 'image': {
|
||||
blocks.push(`[[${path}]]`)
|
||||
} else if (node.type === 'image') {
|
||||
const src = (node.attrs?.src as string) || ''
|
||||
const alt = (node.attrs?.alt as string) || ''
|
||||
return ``
|
||||
blocks.push(``)
|
||||
}
|
||||
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.
|
||||
// 1 empty paragraph = 3 newlines on disk (1 blank line).
|
||||
function serializeBlocksToMarkdown(blocks: JsonNode[]): string {
|
||||
// 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)
|
||||
if (blocks.length === 0) return ''
|
||||
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blockToMarkdown(blocks[i])
|
||||
const block = blocks[i]
|
||||
const isContent = block !== ''
|
||||
|
||||
if (i === 0) {
|
||||
result = block
|
||||
} else if (isContent) {
|
||||
// Content block: add \n\n before it (standard paragraph break)
|
||||
result += '\n\n' + block
|
||||
} else {
|
||||
// Empty block: just add \n (one extra newline for blank line)
|
||||
result += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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 { EditorToolbar } from './editor-toolbar'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||
import { RowboatMentionPopover } from './rowboat-mention-popover'
|
||||
import '@/styles/editor.css'
|
||||
|
|
@ -525,112 +436,7 @@ const TabIndentExtension = Extension.create({
|
|||
},
|
||||
})
|
||||
|
||||
const slugifyHeading = (text: string) =>
|
||||
text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
|
||||
const decodeLinkTarget = (target: string) => {
|
||||
try {
|
||||
return decodeURIComponent(target)
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToHeading = (view: EditorView, rawTarget: string) => {
|
||||
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
|
||||
if (!target) return false
|
||||
|
||||
const targetSlug = slugifyHeading(target)
|
||||
let foundPos: number | null = null
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'heading') return true
|
||||
const headingText = node.textContent.trim()
|
||||
if (
|
||||
headingText.toLowerCase() === target.toLowerCase()
|
||||
|| slugifyHeading(headingText) === targetSlug
|
||||
) {
|
||||
foundPos = pos
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (foundPos === null) return false
|
||||
|
||||
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
|
||||
)
|
||||
view.focus()
|
||||
|
||||
const domAtPos = view.domAtPos(foundPos + 1)
|
||||
const node = domAtPos.node
|
||||
const headingEl = node.nodeType === Node.ELEMENT_NODE
|
||||
? (node as HTMLElement)
|
||||
: node.parentElement
|
||||
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
return true
|
||||
}
|
||||
|
||||
const stripMarkdownExtension = (path: string) =>
|
||||
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
|
||||
|
||||
const isSameNotePath = (linkPath: string, notePath?: string) => {
|
||||
if (!notePath) return false
|
||||
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
|
||||
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
|
||||
return normalizedLink === normalizedNote
|
||||
}
|
||||
|
||||
const isExternalHref = (href: string) =>
|
||||
/^(https?:|mailto:|tel:)/i.test(href)
|
||||
|
||||
const collapseRelativeSegments = (relPath: string) => {
|
||||
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
|
||||
const stack: string[] = []
|
||||
for (const part of parts) {
|
||||
if (part === '..') {
|
||||
if (stack.length === 0) return null
|
||||
stack.pop()
|
||||
} else {
|
||||
stack.push(part)
|
||||
}
|
||||
}
|
||||
return stack.join('/')
|
||||
}
|
||||
|
||||
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
|
||||
const withoutHash = href.split('#')[0]
|
||||
const withoutQuery = withoutHash.split('?')[0]
|
||||
const decoded = decodeLinkTarget(withoutQuery)
|
||||
if (!decoded) return null
|
||||
|
||||
if (/^file:\/\//i.test(decoded)) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(decoded).pathname)
|
||||
} catch {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
|
||||
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
|
||||
|
||||
const noteDir = notePath.split('/').slice(0, -1).join('/')
|
||||
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
|
||||
}
|
||||
|
||||
export interface MarkdownEditorHandle {
|
||||
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
|
||||
getCursorContext: () => { path: string; lineNumber: number } | null
|
||||
}
|
||||
|
||||
export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
|
||||
export function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
onPrimaryHeadingCommit,
|
||||
|
|
@ -645,16 +451,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}, ref) {
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
// Read wikiLinks lazily inside the editor config via this ref. wikiLinks changes
|
||||
// identity whenever the workspace directory tree changes (file watcher → new file
|
||||
// list), and it used to be a useEditor() dependency — so any background write to
|
||||
// the workspace destroyed and recreated the entire editor, resetting scroll to the
|
||||
// top. Keeping it off the dep array (and reading the ref at event time) means the
|
||||
// editor instance survives directory changes.
|
||||
const wikiLinksRef = useRef(wikiLinks)
|
||||
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
||||
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
||||
|
|
@ -677,7 +476,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
|
||||
// Keep ref in sync with state for the plugin to access
|
||||
selectionHighlightRef.current = selectionHighlight
|
||||
wikiLinksRef.current = wikiLinks
|
||||
|
||||
// Memoize the selection highlight extension
|
||||
const selectionHighlightExtension = useMemo(
|
||||
|
|
@ -754,7 +552,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
|
|
@ -772,29 +569,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
PromptBlockExtension.configure({ notePath }),
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
EmailsBlockExtension,
|
||||
EmailBlockExtension,
|
||||
TranscriptBlockExtension,
|
||||
MermaidBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: (path: string) => {
|
||||
void wikiLinksRef.current?.onCreate?.(path)
|
||||
},
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
void wikiLinks.onCreate(path)
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
TableKit.configure({
|
||||
table: { resizable: false },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
|
|
@ -913,57 +705,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||
if (node.type.name === 'wikiLink') {
|
||||
event.preventDefault()
|
||||
const wikiPath = String(node.attrs.path ?? '')
|
||||
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
|
||||
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
|
||||
return scrollToHeading(_view, heading)
|
||||
}
|
||||
wikiLinksRef.current?.onOpen?.(node.attrs.path)
|
||||
wikiLinks?.onOpen?.(node.attrs.path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDOMEvents: {
|
||||
click: (view, event) => {
|
||||
const target = event.target as Element | null
|
||||
const link = target?.closest('a[href]') as HTMLAnchorElement | null
|
||||
if (!link) return false
|
||||
if (link.dataset.type === 'wiki-link') return false
|
||||
|
||||
const href = link.getAttribute('href') ?? ''
|
||||
if (!href) return false
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
event.preventDefault()
|
||||
return scrollToHeading(view, href)
|
||||
}
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
event.preventDefault()
|
||||
window.open(href, '_blank')
|
||||
return true
|
||||
}
|
||||
|
||||
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
|
||||
if (!workspacePath) return false
|
||||
|
||||
event.preventDefault()
|
||||
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
|
||||
if (result.error) console.error('Failed to open linked file:', result.error)
|
||||
}).catch((err) => {
|
||||
console.error('Failed to open linked file:', err)
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
// NOTE: wikiLinks is intentionally NOT a dependency — it's read via wikiLinksRef
|
||||
// at event time. Including it rebuilds the whole editor on every directory change
|
||||
// (file watcher), which resets scroll to the top. See wikiLinksRef declaration.
|
||||
}, [
|
||||
editorSessionKey,
|
||||
maybeCommitPrimaryHeading,
|
||||
notePath,
|
||||
preventTitleHeadingDemotion,
|
||||
promoteFirstParagraphToTitleHeading,
|
||||
])
|
||||
|
|
@ -1035,17 +785,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
})
|
||||
}, [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(() => {
|
||||
if (!editor) return
|
||||
const { selection } = editor.state
|
||||
|
|
@ -1211,37 +950,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
// Normalize for comparison (trim trailing whitespace from lines)
|
||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
// Preserve scroll + selection across an external content sync. setContent()
|
||||
// resets the selection to the top of the doc and ProseMirror scrolls it into
|
||||
// view; without restoring, a background writer touching the open file (graph
|
||||
// builder, live-note runner, version-history commit) yanks the viewport back
|
||||
// to the top repeatedly — making the note impossible to scroll. This editor
|
||||
// instance is bound to a single note path, so the prior scrollTop is always
|
||||
// valid for the reloaded content.
|
||||
const wrapper = wrapperRef.current
|
||||
const prevScrollTop = wrapper?.scrollTop ?? 0
|
||||
const hadFocus = editor.isFocused
|
||||
const { from: prevFrom, to: prevTo } = editor.state.selection
|
||||
|
||||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
|
||||
// Only restore the caret for a focused editor, so we never steal focus or
|
||||
// scroll for a passive viewer. Clamp to the (possibly shorter) new doc.
|
||||
if (hadFocus) {
|
||||
const docSize = editor.state.doc.content.size
|
||||
const from = Math.min(prevFrom, docSize)
|
||||
const to = Math.min(prevTo, docSize)
|
||||
try {
|
||||
editor.chain().setMeta('addToHistory', false).setTextSelection({ from, to }).run()
|
||||
} catch { /* selection no longer valid in the new doc — ignore */ }
|
||||
}
|
||||
isInternalUpdate.current = false
|
||||
|
||||
// Restore scroll last so it wins over any scrollIntoView triggered above.
|
||||
if (wrapper) wrapper.scrollTop = prevScrollTop
|
||||
}
|
||||
}
|
||||
}, [editor, content])
|
||||
|
|
@ -1601,26 +1315,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
return createImageUploadHandler(editor, onImageUpload)
|
||||
}, [editor, onImageUpload])
|
||||
|
||||
// Live-note pill state for the toolbar — derived from the on-disk `live:`
|
||||
// block plus the agent-status bus. The `tick` dependency keeps the relative
|
||||
// time label fresh as minutes roll over.
|
||||
const { live: currentLive, isRunning: liveIsRunning, tick: liveTick } = useLiveNoteForPath(notePath)
|
||||
const livePillStateForCurrentNote: LivePillState = useMemo(() => {
|
||||
void liveTick // re-run on tick to refresh relative-time label
|
||||
if (!currentLive) return { variant: 'passive', label: 'Make live' }
|
||||
if (liveIsRunning) return { variant: 'running', label: 'Updating…' }
|
||||
if (currentLive.lastRunError) {
|
||||
const when = currentLive.lastAttemptAt ? formatRelativeTime(currentLive.lastAttemptAt) : ''
|
||||
return { variant: 'error', label: when ? `Live · failed ${when}` : 'Live · failed' }
|
||||
}
|
||||
if (currentLive.active === false) return { variant: 'passive', label: 'Live · paused' }
|
||||
if (currentLive.lastRunAt) {
|
||||
const when = formatRelativeTime(currentLive.lastRunAt)
|
||||
return { variant: 'idle', label: when ? `Live · ${when}` : 'Live' }
|
||||
}
|
||||
return { variant: 'idle', label: 'Live · never run' }
|
||||
}, [currentLive, liveIsRunning, liveTick])
|
||||
|
||||
return (
|
||||
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
||||
<EditorToolbar
|
||||
|
|
@ -1628,12 +1322,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
onOpenLiveNote={notePath ? () => {
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
|
||||
detail: { filePath: notePath },
|
||||
}))
|
||||
} : undefined}
|
||||
liveState={notePath ? livePillStateForCurrentNote : undefined}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
|
|
@ -1760,4 +1448,4 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -59,14 +59,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -96,20 +96,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
|
||||
const [useComposioForGoogleCalendar] = useState(false)
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -151,8 +151,25 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
// (Composio Gmail/Calendar flag fetches removed — sync was deleted.)
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
|
|
@ -441,8 +458,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -451,8 +466,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
liveNoteAgentModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -605,20 +618,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
||||
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
|
||||
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect, providerStates])
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
@ -1152,72 +1157,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
|
||||
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
|
|
@ -221,76 +221,6 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Meeting Notes Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Track Block Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -66,22 +66,22 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
// Inline upsell callout dismissed
|
||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||
|
||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
// Composio/Gmail state (used when signed in with Rowboat account)
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||
|
||||
const [useComposioForGoogleCalendar] = useState(false)
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -123,8 +123,25 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
// (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.)
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
|
|
@ -418,8 +435,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -428,8 +443,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
liveNoteAgentModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -446,7 +459,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
|
|
@ -522,7 +535,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
|
||||
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
||||
if (event.provider === 'rowboat' && event.success) {
|
||||
// (Composio Gmail/Calendar flag re-check removed — sync was deleted.)
|
||||
// Re-check composio flags now that the account is connected
|
||||
try {
|
||||
const [googleResult, calendarResult] = await Promise.all([
|
||||
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||
])
|
||||
setUseComposioForGoogle(googleResult.enabled)
|
||||
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to re-check composio flags:', error)
|
||||
}
|
||||
setCurrentStep(2) // Go to Connect Accounts
|
||||
}
|
||||
})
|
||||
|
|
@ -582,20 +605,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
||||
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
|
||||
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect, providerStates])
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
|
|
@ -21,66 +21,36 @@ interface SearchResult {
|
|||
path: string
|
||||
}
|
||||
|
||||
export type SearchType = 'knowledge' | 'chat'
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
return ['chat']
|
||||
return ['chat'] // "tasks" tab maps to chat
|
||||
}
|
||||
|
||||
// Retained for any remaining programmatic Copilot entry points (background-agent
|
||||
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
|
||||
export type CommandPaletteContext = {
|
||||
path: string
|
||||
lineNumber: number
|
||||
}
|
||||
|
||||
export type CommandPaletteMention = {
|
||||
path: string
|
||||
displayName: string
|
||||
lineNumber?: number
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
// Overrides the sidebar-section default for the initial scope (e.g. the
|
||||
// knowledge view opens search scoped to knowledge).
|
||||
defaultScope?: SearchType
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelectFile,
|
||||
onSelectRun,
|
||||
defaultScope,
|
||||
}: CommandPaletteProps) {
|
||||
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||
() => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))
|
||||
() => new Set(activeTabToTypes(activeSection))
|
||||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
// Sync filters and clear query when the dialog opens.
|
||||
// Sync filter preselection when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)))
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection, defaultScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
searchInputRef.current?.focus()
|
||||
}, [open])
|
||||
}, [open, activeSection])
|
||||
|
||||
const toggleType = useCallback((type: SearchType) => {
|
||||
setActiveTypes(new Set([type]))
|
||||
|
|
@ -106,15 +76,20 @@ export function CommandPalette({
|
|||
})
|
||||
.catch((err) => {
|
||||
console.error('Search failed:', err)
|
||||
if (!cancelled) setResults([])
|
||||
if (!cancelled) {
|
||||
setResults([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsSearching(false)
|
||||
if (!cancelled) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [debouncedQuery, activeTypes])
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
|
|
@ -144,7 +119,6 @@ export function CommandPalette({
|
|||
className="top-[20%] translate-y-0"
|
||||
>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
|
|
@ -222,19 +196,17 @@ function FilterToggle({
|
|||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export: thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,7 +11,6 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -25,9 +24,8 @@ import { useTheme } from "@/contexts/theme-context"
|
|||
import { toast } from "sonner"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -45,10 +43,10 @@ const tabs: TabConfig[] = [
|
|||
description: "Manage your Rowboat account",
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections",
|
||||
id: "connected-accounts",
|
||||
label: "Connected Accounts",
|
||||
icon: Plug,
|
||||
description: "Manage accounts and tools",
|
||||
description: "Manage connected services",
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
|
|
@ -71,18 +69,18 @@ const tabs: TabConfig[] = [
|
|||
path: "config/security.json",
|
||||
description: "Configure allowed shell commands",
|
||||
},
|
||||
{
|
||||
id: "code-mode",
|
||||
label: "Code Mode",
|
||||
icon: Terminal,
|
||||
description: "Delegate coding tasks to Claude Code or Codex",
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools Library",
|
||||
icon: Wrench,
|
||||
description: "Browse and enable toolkits",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
|
|
@ -90,93 +88,10 @@ const tabs: TabConfig[] = [
|
|||
path: "config/tags.json",
|
||||
description: "Configure tags for notes and emails",
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
icon: HelpCircle,
|
||||
description: "Get help and support",
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
/** Optional trigger element. Omit when controlling `open` externally. */
|
||||
children?: React.ReactNode
|
||||
/** Tab to open on when the dialog is shown. Defaults to "account". */
|
||||
defaultTab?: ConfigTab
|
||||
/** Controlled open state. When provided, the dialog is fully controlled. */
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
// --- Help & Support tab ---
|
||||
|
||||
function HelpSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Help & Support</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
|
||||
<Bug className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Report a bug</span>
|
||||
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
|
||||
<MessageCircle className="size-4 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Join our Discord</span>
|
||||
<span className="text-xs text-muted-foreground">Chat with the community</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Contact us</span>
|
||||
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
|
||||
</div>
|
||||
</Button>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<a
|
||||
href="https://www.rowboatlabs.com/terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<span>·</span>
|
||||
<a
|
||||
href="https://www.rowboatlabs.com/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// --- Theme option for Appearance tab ---
|
||||
|
|
@ -211,7 +126,7 @@ function ThemeOption({
|
|||
}
|
||||
|
||||
function AppearanceSettings() {
|
||||
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -241,50 +156,6 @@ function AppearanceSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Chat</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose where chat sits when another pane is open
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ThemeOption
|
||||
label="Chat right"
|
||||
icon={PanelRight}
|
||||
isSelected={chatPanePlacement === "right"}
|
||||
onClick={() => setChatPanePlacement("right")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat middle"
|
||||
icon={MessageCircle}
|
||||
isSelected={chatPanePlacement === "middle"}
|
||||
onClick={() => setChatPanePlacement("middle")}
|
||||
/>
|
||||
</div>
|
||||
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose how much width chat gets when another pane is open
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ThemeOption
|
||||
label="Chat smaller"
|
||||
icon={MessageCircle}
|
||||
isSelected={chatPaneSize === "chat-smaller"}
|
||||
onClick={() => setChatPaneSize("chat-smaller")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat equal"
|
||||
icon={Monitor}
|
||||
isSelected={chatPaneSize === "chat-equal"}
|
||||
onClick={() => setChatPaneSize("chat-equal")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat bigger"
|
||||
icon={PanelRight}
|
||||
isSelected={chatPaneSize === "chat-bigger"}
|
||||
onClick={() => setChatPaneSize("chat-bigger")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -322,27 +193,17 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
|||
"openai-compatible": "http://localhost:1234/v1",
|
||||
}
|
||||
|
||||
type ProviderModelConfig = {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
models: string[]
|
||||
knowledgeGraphModel: string
|
||||
meetingNotesModel: string
|
||||
liveNoteAgentModel: string
|
||||
autoPermissionDecisionModel: string
|
||||
}
|
||||
|
||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
|
|
@ -368,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
|
|
@ -441,9 +302,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||
models: savedModels,
|
||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||
meetingNotesModel: e.meetingNotesModel || "",
|
||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -460,9 +318,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||
models: activeModels.length > 0 ? activeModels : [""],
|
||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
|
|
@ -536,9 +391,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
model: allModels[0] || "",
|
||||
models: allModels,
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -571,9 +423,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
model: allModels[0],
|
||||
models: allModels,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
})
|
||||
setDefaultProvider(prov)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -603,9 +452,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
parsed.model = defModels[0] || ""
|
||||
parsed.models = defModels
|
||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
|
|
@ -613,7 +459,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
})
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -803,108 +649,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meeting notes model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track block model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-permission model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.autoPermissionDecisionModel}
|
||||
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
@ -1748,255 +1492,11 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Code Mode Settings ---
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
function AgentStatusRow({
|
||||
name,
|
||||
installLink,
|
||||
signInCommand,
|
||||
status,
|
||||
}: {
|
||||
name: string
|
||||
installLink: string
|
||||
signInCommand: string
|
||||
status: AgentStatus | null
|
||||
}) {
|
||||
const ready = status?.installed && status?.signedIn
|
||||
const needsSignInOnly = status?.installed && !status?.signedIn
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
||||
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Installed
|
||||
</span>
|
||||
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Signed in
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{ready ? (
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Ready
|
||||
</span>
|
||||
) : needsSignInOnly ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={installLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
Install & sign in
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(false)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
setStatusLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||
setStatus(result)
|
||||
} catch {
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setStatusLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
||||
if (!cancelled) {
|
||||
setEnabled(result.enabled)
|
||||
setApprovalPolicy(result.approvalPolicy ?? 'ask')
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setEnabled(false)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
loadStatus()
|
||||
return () => { cancelled = true }
|
||||
}, [dialogOpen, loadStatus])
|
||||
|
||||
const handleToggle = useCallback(async (next: boolean) => {
|
||||
setSaving(true)
|
||||
setEnabled(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
||||
} catch {
|
||||
setEnabled(!next)
|
||||
toast.error("Failed to update code mode")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [approvalPolicy])
|
||||
|
||||
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
|
||||
const prev = approvalPolicy
|
||||
setSaving(true)
|
||||
setApprovalPolicy(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
} catch {
|
||||
setApprovalPolicy(prev)
|
||||
toast.error("Failed to update approval policy")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [enabled, approvalPolicy])
|
||||
|
||||
const anyReady = status?.claude.installed && status?.claude.signedIn
|
||||
|| status?.codex.installed && status?.codex.signedIn
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
||||
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
||||
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
|
||||
and streams its work — tool calls, file diffs, and approvals — back into chat.
|
||||
</p>
|
||||
<p>
|
||||
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
||||
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
|
||||
<button
|
||||
onClick={() => { void loadStatus() }}
|
||||
disabled={statusLoading}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
Re-check
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<AgentStatusRow
|
||||
name="Claude Code"
|
||||
installLink="https://claude.ai/code"
|
||||
signInCommand="claude login"
|
||||
status={status?.claude ?? null}
|
||||
/>
|
||||
<AgentStatusRow
|
||||
name="Codex"
|
||||
installLink="https://developers.openai.com/codex/cli"
|
||||
signInCommand="codex login"
|
||||
status={status?.codex ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Enable code mode</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="rounded-md border px-3 py-3 space-y-2">
|
||||
<div className="text-sm font-medium">Approvals</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
How the coding agent checks in before changing files or running commands. You always see
|
||||
everything it does in the timeline — this only controls the prompts.
|
||||
</div>
|
||||
<Select
|
||||
value={approvalPolicy}
|
||||
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ask">Ask every time</SelectItem>
|
||||
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
|
||||
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
|
||||
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
|
||||
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && status && !anyReady && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-amber-900 dark:text-amber-200">
|
||||
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
|
||||
account, then click Re-check.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = controlledOpen ?? internalOpen
|
||||
const setOpen = useCallback((next: boolean) => {
|
||||
if (onOpenChange) onOpenChange(next)
|
||||
else setInternalOpen(next)
|
||||
}, [onOpenChange])
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
|
||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -2004,11 +1504,6 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [rowboatConnected, setRowboatConnected] = useState(false)
|
||||
|
||||
// Reset to the requested default tab each time the dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab)
|
||||
}, [open, defaultTab])
|
||||
|
||||
// Check if user is signed in to Rowboat
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -2034,7 +1529,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -2100,7 +1595,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
||||
>
|
||||
|
|
@ -2142,21 +1637,11 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Primary accounts</h4>
|
||||
) : activeTab === "connected-accounts" ? (
|
||||
<ConnectedAccountsSettings dialogOpen={open} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Library</h4>
|
||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? <RowboatModelSettings dialogOpen={open} />
|
||||
|
|
@ -2165,10 +1650,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
<CodeModeSettings dialogOpen={open} />
|
||||
) : activeTab === "tools" ? (
|
||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -17,44 +17,11 @@ import {
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "sonner"
|
||||
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
interface AccountSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
function formatPlanName(plan: string | null | undefined) {
|
||||
if (!plan) return 'No Plan'
|
||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
|
||||
}
|
||||
|
||||
function CreditUsageBar({ label, bucket, helper }: {
|
||||
label: string
|
||||
bucket: BillingUsageBucket
|
||||
helper?: string
|
||||
}) {
|
||||
const pct = bucket.sanctionedCredits > 0
|
||||
? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100)))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
{helper ? <p className="text-[11px] text-muted-foreground">{helper}</p> : null}
|
||||
</div>
|
||||
<p className="shrink-0 text-xs font-medium tabular-nums">
|
||||
{pct}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [connectionLoading, setConnectionLoading] = useState(true)
|
||||
|
|
@ -62,7 +29,6 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -197,7 +163,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{formatPlanName(billing.subscriptionPlan)}
|
||||
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
|
||||
</p>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
|
|
@ -214,17 +180,9 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<CreditUsageBar label="Plan usage" bucket={billing.monthly} />
|
||||
<CreditUsageBar
|
||||
label="Daily use"
|
||||
bucket={billing.daily}
|
||||
helper="Daily usage resets at 00:00 UTC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
||||
|
|
@ -245,15 +203,15 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPaidSubscription}
|
||||
disabled={!billing?.subscriptionPlan}
|
||||
onClick={() => appUrl && window.open(appUrl)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Manage in Stripe
|
||||
</Button>
|
||||
{!hasPaidSubscription && (
|
||||
<p className="text-[11px] text-muted-foreground">Upgrade to a paid plan first</p>
|
||||
{!billing?.subscriptionPlan && (
|
||||
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
|
@ -52,7 +52,16 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => c.handleReconnect(provider)}
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
c.setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
c.setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
c.startConnect(provider)
|
||||
}}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Reconnect
|
||||
|
|
@ -119,15 +128,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
{/* Email & Calendar Section */}
|
||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Email & Calendar
|
||||
</span>
|
||||
</div>
|
||||
{c.useComposioForGoogle ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
|
@ -174,9 +183,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||
)}
|
||||
{c.useComposioForGoogleCalendar && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Calendar className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
|
@ -220,14 +229,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
<Separator className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes Section */}
|
||||
{c.providers.includes('fireflies-ai') && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export function TabBar<T>({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rowboat-tabbar flex flex-1 self-stretch min-w-0',
|
||||
'flex flex-1 self-stretch min-w-0',
|
||||
layout === 'scroll'
|
||||
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
: 'overflow-hidden'
|
||||
|
|
@ -57,7 +57,7 @@ export function TabBar<T>({
|
|||
type="button"
|
||||
onClick={() => onSwitchTab(tabId)}
|
||||
className={cn(
|
||||
'rowboat-tab titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
|
||||
isActive
|
||||
? 'bg-background text-foreground'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,32 +3,16 @@
|
|||
import * as React from "react"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
export type ChatPanePlacement = "right" | "middle"
|
||||
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
|
||||
|
||||
type ThemeContextProps = {
|
||||
theme: Theme
|
||||
resolvedTheme: "light" | "dark"
|
||||
setTheme: (theme: Theme) => void
|
||||
chatPanePlacement: ChatPanePlacement
|
||||
setChatPanePlacement: (placement: ChatPanePlacement) => void
|
||||
chatPaneSize: ChatPaneSize
|
||||
setChatPaneSize: (size: ChatPaneSize) => void
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
||||
|
||||
const STORAGE_KEY = "rowboat-theme"
|
||||
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
|
||||
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
|
||||
|
||||
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
|
||||
return value === "right" || value === "middle"
|
||||
}
|
||||
|
||||
function isChatPaneSize(value: string | null): value is ChatPaneSize {
|
||||
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
|
||||
}
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light"
|
||||
|
|
@ -55,16 +39,6 @@ export function ThemeProvider({
|
|||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||
return stored || defaultTheme
|
||||
})
|
||||
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
|
||||
if (typeof window === "undefined") return "right"
|
||||
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
|
||||
return isChatPanePlacement(stored) ? stored : "right"
|
||||
})
|
||||
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
|
||||
if (typeof window === "undefined") return "chat-smaller"
|
||||
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
|
||||
return isChatPaneSize(stored) ? stored : "chat-smaller"
|
||||
})
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||
if (theme === "system") return getSystemTheme()
|
||||
|
|
@ -102,27 +76,13 @@ export function ThemeProvider({
|
|||
setThemeState(newTheme)
|
||||
}, [])
|
||||
|
||||
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
|
||||
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
|
||||
setChatPanePlacementState(placement)
|
||||
}, [])
|
||||
|
||||
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
|
||||
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
|
||||
setChatPaneSizeState(size)
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo<ThemeContextProps>(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
chatPanePlacement,
|
||||
setChatPanePlacement,
|
||||
chatPaneSize,
|
||||
setChatPaneSize,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
|
||||
[theme, resolvedTheme, setTheme]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
|||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { extractConferenceLink } from '../lib/calendar-event'
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
|
|
@ -41,6 +40,25 @@ function getTimeRange(event: blocks.CalendarEvent): string {
|
|||
return `${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to conferenceLink if already set.
|
||||
*/
|
||||
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
// Check conferenceData.entryPoints for video entry
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
// Check hangoutLink (Google Meet shortcut)
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
// Fall back to conferenceLink if present
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface ResolvedEvent {
|
||||
event: blocks.CalendarEvent
|
||||
loaded: blocks.CalendarEvent | null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
|
||||
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
|
@ -11,47 +11,17 @@ function formatEmailDate(dateStr: string): string {
|
|||
try {
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
const now = new Date()
|
||||
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function formatFullDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function extractName(from: string): string {
|
||||
const match = from.match(/^([^<]+)</)
|
||||
if (match) return match[1].trim()
|
||||
const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
|
||||
return username.replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function getInitial(from: string): string {
|
||||
const name = extractName(from)
|
||||
return (name[0] || '?').toUpperCase()
|
||||
}
|
||||
|
||||
const GMAIL_AVATAR_COLORS = [
|
||||
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
|
||||
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
|
||||
]
|
||||
|
||||
function avatarColor(from: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0
|
||||
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
|
||||
/** Extract just the name part from "Name <email>" format */
|
||||
function senderFirstName(from: string): string {
|
||||
const name = from.replace(/<.*>/, '').trim()
|
||||
return name.split(/\s+/)[0] || name
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -60,307 +30,7 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Shared: expanded email body used by both block types ---
|
||||
|
||||
function EmailExpandedBody({
|
||||
config,
|
||||
resolvedTheme,
|
||||
}: {
|
||||
config: blocks.EmailBlock
|
||||
resolvedTheme: string
|
||||
}) {
|
||||
const [draftBody, setDraftBody] = useState(config.draft_response || '')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDraftBody(config.draft_response || '')
|
||||
}, [config.draft_response])
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.style.height = 'auto'
|
||||
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
||||
}
|
||||
}, [draftBody])
|
||||
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
}
|
||||
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config, draftBody])
|
||||
|
||||
const copyDraft = useCallback(() => {
|
||||
navigator.clipboard.writeText(draftBody).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = draftBody
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}, [draftBody])
|
||||
|
||||
const gmailUrl = config.threadId
|
||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
|
||||
const initial = config.from ? getInitial(config.from) : '?'
|
||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
||||
const hasDraft = !!config.draft_response
|
||||
|
||||
return (
|
||||
<div className="email-gmail-expanded">
|
||||
{config.subject && (
|
||||
<div className="email-gmail-exp-subject">{config.subject}</div>
|
||||
)}
|
||||
|
||||
<div className="email-gmail-exp-meta">
|
||||
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
<div className="email-gmail-exp-meta-right">
|
||||
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
|
||||
<div className="email-gmail-exp-to-date">
|
||||
{config.to && <span>to {config.to}</span>}
|
||||
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="email-gmail-exp-body">{config.latest_email}</div>
|
||||
|
||||
{config.past_summary && (
|
||||
<div className="email-gmail-exp-history">
|
||||
<div className="email-gmail-exp-history-label">Earlier conversation</div>
|
||||
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasDraft && (
|
||||
<div className="email-gmail-reply-row">
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-gmail-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
Draft with Rowboat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDraft && (
|
||||
<div className="email-gmail-compose">
|
||||
<div className="email-gmail-compose-to">
|
||||
<span className="email-gmail-compose-to-label">Reply</span>
|
||||
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
|
||||
</div>
|
||||
<textarea
|
||||
key={resolvedTheme}
|
||||
ref={bodyRef}
|
||||
className="email-gmail-compose-body"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="email-gmail-compose-footer">
|
||||
<button
|
||||
className="email-gmail-btn email-gmail-btn-primary"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||
</button>
|
||||
<button
|
||||
className="email-gmail-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); copyDraft() }}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied!' : 'Copy draft'}
|
||||
</button>
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-gmail-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Multi-email inbox block (language-emails) ---
|
||||
|
||||
function EmailsBlockView({ node, deleteNode }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmailsBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
|
||||
} catch { /* fallback below */ }
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
|
||||
|
||||
if (!config || config.emails.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
|
||||
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
|
||||
<div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
|
||||
|
||||
{config.title && (
|
||||
<div className="email-inbox-title">{config.title}</div>
|
||||
)}
|
||||
|
||||
<div className="email-inbox-list">
|
||||
{config.emails.map((email, i) => {
|
||||
const isExpanded = expandedIndex === i
|
||||
const senderName = email.from ? extractName(email.from) : 'Unknown'
|
||||
const initial = email.from ? getInitial(email.from) : '?'
|
||||
const color = email.from ? avatarColor(email.from) : '#5f6368'
|
||||
const snippet = email.summary
|
||||
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
|
||||
|
||||
return (
|
||||
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
|
||||
{/* Collapsed row */}
|
||||
<div
|
||||
className="email-inbox-row-header"
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
|
||||
<div className="email-inbox-content">
|
||||
<div className="email-inbox-top-row">
|
||||
<span className="email-inbox-sender">{senderName}</span>
|
||||
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
|
||||
</div>
|
||||
<div className="email-inbox-bottom-row">
|
||||
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
|
||||
{snippet && (
|
||||
<span className="email-inbox-snippet">
|
||||
{email.subject ? ` — ${snippet}` : snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="email-inbox-expanded-wrap">
|
||||
<EmailExpandedBody
|
||||
config={email}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmailsBlockExtension = Node.create({
|
||||
name: 'emailsBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return { data: { default: '{}' } }
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'pre',
|
||||
priority: 61,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
if ((code.className || '').includes('language-emails')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmailsBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```emails\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// --- Single email block (language-email, backward compat) ---
|
||||
// --- Email Block ---
|
||||
|
||||
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
|
|
@ -372,58 +42,195 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
|
||||
try {
|
||||
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||
} catch { /* fallback below */ }
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const hasDraft = !!config?.draft_response
|
||||
const hasPastSummary = !!config?.past_summary
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
void updateAttributes // available for future per-email draft persistence
|
||||
// Local draft state for editing
|
||||
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||
const [emailExpanded, setEmailExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync draft from external changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||
setDraftBody(parsed.draft_response || '')
|
||||
} catch { /* ignore */ }
|
||||
}, [raw])
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.style.height = 'auto'
|
||||
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
||||
}
|
||||
}, [draftBody])
|
||||
|
||||
const commitDraft = useCallback((newBody: string) => {
|
||||
try {
|
||||
const current = JSON.parse(raw) as Record<string, unknown>
|
||||
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
|
||||
} catch { /* ignore */ }
|
||||
}, [raw, updateAttributes])
|
||||
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
if (!config) return
|
||||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
}
|
||||
prompt += `.\n\n`
|
||||
prompt += `**From:** ${config.from || 'Unknown'}\n`
|
||||
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) {
|
||||
prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
}
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config, draftBody])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
|
||||
<div className="email-block-card email-block-error">
|
||||
<Mail size={16} />
|
||||
<span>Invalid email block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const senderName = config.from ? extractName(config.from) : 'Unknown'
|
||||
const initial = config.from ? getInitial(config.from) : '?'
|
||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
||||
const snippet = config.summary
|
||||
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
|
||||
const gmailUrl = config.threadId
|
||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
|
||||
// Build summary: use explicit summary, or auto-generate from sender + subject
|
||||
const summary = config.summary
|
||||
|| (config.from && config.subject
|
||||
? `${senderFirstName(config.from)} reached out about ${config.subject}`
|
||||
: config.subject || 'New email')
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
|
||||
{/* Header: Email badge */}
|
||||
<div className="email-block-badge">
|
||||
<Mail size={13} />
|
||||
Email
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="email-block-summary">{summary}</div>
|
||||
|
||||
{/* Expandable email details */}
|
||||
<button
|
||||
className="email-block-expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div>
|
||||
<div className="email-gmail-content">
|
||||
<div className="email-gmail-top-row">
|
||||
<span className="email-gmail-sender">{senderName}</span>
|
||||
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
|
||||
</div>
|
||||
<div className="email-gmail-bottom-row">
|
||||
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
|
||||
{snippet && <span className="email-gmail-snippet">{config.subject ? ` — ${snippet}` : snippet}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
|
||||
</div>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{emailExpanded ? 'Hide email' : 'Show email'}
|
||||
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
|
||||
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<EmailExpandedBody
|
||||
config={config}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
{emailExpanded && (
|
||||
<div className="email-block-email-details">
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
<div className="email-block-sender-info">
|
||||
<div className="email-block-sender-row">
|
||||
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</div>
|
||||
{hasPastSummary && (
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-context-label">Earlier conversation</div>
|
||||
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft section */}
|
||||
{hasDraft && (
|
||||
<div className="email-block-draft-section">
|
||||
<div className="email-block-draft-label">Draft reply</div>
|
||||
<textarea
|
||||
key={resolvedTheme}
|
||||
ref={bodyRef}
|
||||
className="email-draft-block-body-input"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onBlur={() => commitDraft(draftBody)}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="email-block-actions">
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={draftWithAssistant}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||
</button>
|
||||
{hasDraft && (
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(draftBody).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {
|
||||
// Fallback for Electron contexts where clipboard API may fail
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = draftBody
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied!' : 'Copy draft'}
|
||||
</button>
|
||||
)}
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={() => window.open(gmailUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
|
@ -436,7 +243,9 @@ export const EmailBlockExtension = Node.create({
|
|||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return { data: { default: '{}' } }
|
||||
return {
|
||||
data: { default: '{}' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
|
|
@ -447,7 +256,7 @@ export const EmailBlockExtension = Node.create({
|
|||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) {
|
||||
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { Tweet } from 'react-tweet'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function getEmbedUrl(provider: string, url: string): string | null {
|
||||
|
|
@ -25,28 +24,6 @@ function getEmbedUrl(provider: string, url: string): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
function extractTweetId(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const hostname = parsed.hostname
|
||||
.toLowerCase()
|
||||
.replace(/^www\./, '')
|
||||
.replace(/^mobile\./, '')
|
||||
if (hostname !== 'twitter.com' && hostname !== 'x.com') return null
|
||||
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if ((segments[i] === 'status' || segments[i] === 'statuses') && /^\d+$/.test(segments[i + 1])) {
|
||||
return segments[i + 1]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmbedBlock | null = null
|
||||
|
|
@ -68,7 +45,6 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
)
|
||||
}
|
||||
|
||||
const tweetId = extractTweetId(config.url)
|
||||
const embedUrl = getEmbedUrl(config.provider, config.url)
|
||||
|
||||
return (
|
||||
|
|
@ -81,14 +57,7 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.provider === 'tweet' && tweetId ? (
|
||||
<div
|
||||
className="embed-block-tweet-shell"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Tweet id={tweetId} />
|
||||
</div>
|
||||
) : embedUrl ? (
|
||||
{embedUrl ? (
|
||||
<div className="embed-block-iframe-container">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
|
||||
import { Node, mergeAttributes } from '@tiptap/react'
|
||||
import { InputRule, inputRules } from '@tiptap/pm/inputrules'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||
|
||||
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
|
||||
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
|
||||
|
|
@ -25,12 +26,9 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
|||
for (const match of matches) {
|
||||
const matchIndex = match.index ?? 0
|
||||
const matchText = match[0] ?? ''
|
||||
const rawLink = match[1]?.trim() ?? ''
|
||||
const { label } = splitWikiAlias(rawLink)
|
||||
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
|
||||
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
|
||||
const isHeadingOnlyLink = !basePath && Boolean(heading)
|
||||
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
|
||||
const rawPath = match[1]?.trim() ?? ''
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
|
||||
|
||||
if (matchIndex > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
|
||||
|
|
@ -38,8 +36,7 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
|||
|
||||
if (isValidPath) {
|
||||
const el = document.createElement('wiki-link')
|
||||
el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
|
||||
if (label) el.setAttribute('data-label', label)
|
||||
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
|
||||
fragment.appendChild(el)
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(matchText))
|
||||
|
|
@ -84,9 +81,6 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
path: {
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -94,36 +88,30 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
return [
|
||||
{
|
||||
tag: 'wiki-link[data-path]',
|
||||
getAttrs: (element: Element) => ({
|
||||
getAttrs: (element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
label: (element as HTMLElement).getAttribute('data-label'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a[data-type="wiki-link"]',
|
||||
getAttrs: (element: Element) => ({
|
||||
getAttrs: (element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
label: (element as HTMLElement).getAttribute('data-label'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
|
||||
const label = wikiLabel(node.attrs.path) || node.attrs.path
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'wiki-link',
|
||||
'data-path': node.attrs.path,
|
||||
'href': '#',
|
||||
'class': 'wiki-link',
|
||||
'aria-label': node.attrs.path,
|
||||
},
|
||||
node.attrs.label ? { 'data-label': node.attrs.label } : {}
|
||||
),
|
||||
}),
|
||||
label,
|
||||
]
|
||||
},
|
||||
|
|
@ -133,8 +121,7 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
markdown: {
|
||||
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
|
||||
const path = node.attrs.path ?? ''
|
||||
const label = (node.attrs as { label?: string }).label
|
||||
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
|
||||
state.write(`[[${path}]]`)
|
||||
},
|
||||
parse: {
|
||||
updateDOM(element: HTMLElement) {
|
||||
|
|
@ -145,29 +132,23 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
addProseMirrorPlugins() {
|
||||
const onCreate = this.options.onCreate
|
||||
return [
|
||||
new InputRule({
|
||||
find: wikiLinkInputRegex,
|
||||
handler: ({ state, range, match }) => {
|
||||
const rawLink = match[1]?.trim()
|
||||
const { label } = splitWikiAlias(rawLink ?? '')
|
||||
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
|
||||
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
|
||||
const isHeadingOnlyLink = !basePath && Boolean(heading)
|
||||
if (
|
||||
!normalizedPath
|
||||
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
|
||||
) return null
|
||||
const rules = [
|
||||
new InputRule(wikiLinkInputRegex, (state, match, start, end) => {
|
||||
const rawPath = match[1]?.trim()
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||
if (state.selection.$from.parent.type.spec.code) return null
|
||||
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
||||
|
||||
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
|
||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
|
||||
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||
const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath }))
|
||||
onCreate?.(finalPath)
|
||||
},
|
||||
return tr
|
||||
}),
|
||||
]
|
||||
|
||||
return [inputRules({ rules })]
|
||||
},
|
||||
})
|
||||
|
|
|
|||
1
apps/x/apps/renderer/src/global.d.ts
vendored
1
apps/x/apps/renderer/src/global.d.ts
vendored
|
|
@ -35,7 +35,6 @@ declare global {
|
|||
};
|
||||
electronUtils: {
|
||||
getPathForFile: (file: File) => string;
|
||||
getZoomFactor: () => number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
|
||||
|
||||
/**
|
||||
* Identifies the user in PostHog when signed into Rowboat,
|
||||
|
|
@ -18,7 +17,7 @@ export function useAnalyticsIdentity() {
|
|||
// Identify if Rowboat account is connected
|
||||
const rowboat = config.rowboat
|
||||
if (rowboat?.connected && rowboat?.userId) {
|
||||
identifyUser(rowboat.userId)
|
||||
posthog.identify(rowboat.userId)
|
||||
}
|
||||
|
||||
// Set provider connection flags
|
||||
|
|
@ -59,29 +58,15 @@ export function useAnalyticsIdentity() {
|
|||
// Listen for OAuth connect/disconnect events to update identity
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider !== 'rowboat') {
|
||||
// Other providers: just toggle the connection flag
|
||||
if (event.success) {
|
||||
if (!event.success) return
|
||||
|
||||
// If Rowboat provider connected, identify user
|
||||
if (event.provider === 'rowboat' && event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
posthog.people.set({ signed_in: true })
|
||||
}
|
||||
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Rowboat sign-in
|
||||
if (event.success) {
|
||||
if (event.userId) {
|
||||
identifyUser(event.userId)
|
||||
}
|
||||
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
||||
posthog.capture('user_signed_in')
|
||||
return
|
||||
}
|
||||
|
||||
// Rowboat sign-out — flip flags, capture, and reset distinct_id so
|
||||
// future events on this device don't get attributed to the prior user.
|
||||
posthog.people.set({ signed_in: false, rowboat_connected: false })
|
||||
posthog.capture('user_signed_out')
|
||||
resetAnalyticsIdentity()
|
||||
})
|
||||
|
||||
return cleanup
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { BillingInfo } from '@x/shared/dist/billing.js'
|
||||
|
||||
interface BillingInfo {
|
||||
userEmail: string | null
|
||||
userId: string | null
|
||||
subscriptionPlan: string | null
|
||||
subscriptionStatus: string | null
|
||||
trialExpiresAt: string | null
|
||||
sanctionedCredits: number
|
||||
availableCredits: number
|
||||
}
|
||||
|
||||
export function useBilling(isRowboatConnected: boolean) {
|
||||
const [billing, setBilling] = useState<BillingInfo | null>(null)
|
||||
|
|
|
|||
|
|
@ -38,21 +38,16 @@ export function useConnectors(active: boolean) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
||||
// and never flipped — the IPC that used to set them is gone. The setters
|
||||
// remain so the legacy Composio-Gmail handlers below still type-check,
|
||||
// but those handlers are no longer reachable in the UI (the gating
|
||||
// condition `useComposioForGoogle` stays false).
|
||||
// TODO follow-up: drop these flags entirely and prune the dead UI branches
|
||||
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
|
||||
const [useComposioForGoogleCalendar] = useState(false)
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
// Load available providers on mount
|
||||
|
|
@ -72,7 +67,28 @@ export function useConnectors(active: boolean) {
|
|||
loadProviders()
|
||||
}, [])
|
||||
|
||||
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
|
||||
// Re-check composio-for-google flags when active
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [active])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
|
|
@ -330,22 +346,13 @@ export function useConnectors(active: boolean) {
|
|||
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
||||
// the webapp in the browser, no BYOK modal. Main process detects
|
||||
// signed-in via isSignedIn() when oauth:connect arrives without creds.
|
||||
// Falls back to the BYOK modal for not-signed-in users.
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdDescription(undefined)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect, providerStates])
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
@ -354,25 +361,6 @@ export function useConnectors(active: boolean) {
|
|||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
// Reconnect flow used by the "Reconnect" button. Mirrors handleConnect's
|
||||
// rowboat-vs-BYOK branching for Google so signed-in users don't get the
|
||||
// client-ID modal — they just re-run the managed-credentials browser flow.
|
||||
const handleReconnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
await startConnect(provider)
|
||||
}, [startConnect, providerStates])
|
||||
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -497,6 +485,19 @@ export function useConnectors(active: boolean) {
|
|||
toast.success(`Connected to ${displayName}`)
|
||||
}
|
||||
|
||||
if (provider === 'rowboat') {
|
||||
try {
|
||||
const [googleResult, calendarResult] = await Promise.all([
|
||||
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||
])
|
||||
setUseComposioForGoogle(googleResult.enabled)
|
||||
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||
} catch (err) {
|
||||
console.error('Failed to re-check composio flags:', err)
|
||||
}
|
||||
}
|
||||
|
||||
refreshAllStatuses()
|
||||
}
|
||||
})
|
||||
|
|
@ -553,7 +554,6 @@ export function useConnectors(active: boolean) {
|
|||
providerStatus,
|
||||
hasProviderError,
|
||||
handleConnect,
|
||||
handleReconnect,
|
||||
handleDisconnect,
|
||||
startConnect,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +1,5 @@
|
|||
import posthog from 'posthog-js'
|
||||
|
||||
let appVersion: string | undefined
|
||||
let apiUrl: string | undefined
|
||||
|
||||
function appVersionProperties(): Record<string, string> {
|
||||
return appVersion ? { app_version: appVersion } : {}
|
||||
}
|
||||
|
||||
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
|
||||
appVersion = props.appVersion?.trim() || undefined
|
||||
apiUrl = props.apiUrl?.trim() || undefined
|
||||
|
||||
const eventProperties = appVersionProperties()
|
||||
if (Object.keys(eventProperties).length > 0) {
|
||||
posthog.register(eventProperties)
|
||||
}
|
||||
|
||||
const personProperties = {
|
||||
...(apiUrl ? { api_url: apiUrl } : {}),
|
||||
...eventProperties,
|
||||
}
|
||||
if (Object.keys(personProperties).length > 0) {
|
||||
posthog.people.set(personProperties)
|
||||
}
|
||||
}
|
||||
|
||||
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||
posthog.identify(userId, {
|
||||
...properties,
|
||||
...appVersionProperties(),
|
||||
})
|
||||
}
|
||||
|
||||
export function resetAnalyticsIdentity() {
|
||||
posthog.reset()
|
||||
configureAnalyticsContext({ appVersion, apiUrl })
|
||||
}
|
||||
|
||||
export function chatSessionCreated(runId: string) {
|
||||
posthog.capture('chat_session_created', { run_id: runId })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 & back to & in the URL.
|
||||
return match ? match[0].replace(/&/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
Loading…
Add table
Add a link
Reference in a new issue