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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24.15.0
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
|
@ -111,7 +111,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: distributables
|
name: distributables
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
|
|
@ -129,7 +128,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24.15.0
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
|
@ -176,7 +175,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: distributables-linux
|
name: distributables-linux
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
|
|
@ -194,7 +192,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24.15.0
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
|
@ -243,5 +241,4 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: distributables-windows
|
name: distributables-windows
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,4 +3,3 @@
|
||||||
.vscode/
|
.vscode/
|
||||||
data/
|
data/
|
||||||
.venv/
|
.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` |
|
| Workspace config | `apps/x/pnpm-workspace.yaml` |
|
||||||
| Root scripts | `apps/x/package.json` |
|
| Root scripts | `apps/x/package.json` |
|
||||||
|
|
||||||
## Feature Deep-Dives
|
|
||||||
|
|
||||||
Long-form docs for specific features. Read the relevant file before making changes in that area — it has the full product flow, technical flows, and (where applicable) a catalog of the LLM prompts involved with exact file:line pointers.
|
|
||||||
|
|
||||||
| Feature | Doc |
|
|
||||||
|---------|-----|
|
|
||||||
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
|
|
||||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### LLM configuration (single provider)
|
### LLM configuration (single provider)
|
||||||
|
|
|
||||||
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/
|
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 * as esbuild from 'esbuild';
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
|
|
||||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||||
// The banner defines __import_meta_url at the top of the bundle,
|
// The banner defines __import_meta_url at the top of the bundle,
|
||||||
// and we use define to replace all import.meta.url references with it.
|
// and we use define to replace all import.meta.url references with it.
|
||||||
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
||||||
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
|
||||||
|
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
entryPoints: ['./dist/main.js'],
|
entryPoints: ['./dist/main.js'],
|
||||||
|
|
@ -33,12 +31,6 @@ await esbuild.build({
|
||||||
// Replace import.meta.url directly with our polyfill variable
|
// Replace import.meta.url directly with our polyfill variable
|
||||||
define: {
|
define: {
|
||||||
'import.meta.url': '__import_meta_url',
|
'import.meta.url': '__import_meta_url',
|
||||||
// Inject PostHog credentials at build time. Reuse the renderer's
|
|
||||||
// VITE_PUBLIC_* envs so packaging only needs one set of values.
|
|
||||||
// Empty strings disable analytics gracefully.
|
|
||||||
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
|
|
||||||
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
|
|
||||||
'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ module.exports = {
|
||||||
icon: './icons/icon', // .icns extension added automatically
|
icon: './icons/icon', // .icns extension added automatically
|
||||||
appBundleId: 'com.rowboat.app',
|
appBundleId: 'com.rowboat.app',
|
||||||
appCategoryType: 'public.app-category.productivity',
|
appCategoryType: 'public.app-category.productivity',
|
||||||
protocols: [
|
|
||||||
{ name: 'Rowboat', schemes: ['rowboat'] },
|
|
||||||
],
|
|
||||||
extendInfo: {
|
extendInfo: {
|
||||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||||
},
|
},
|
||||||
|
|
@ -56,7 +53,6 @@ module.exports = {
|
||||||
description: 'AI coworker with memory',
|
description: 'AI coworker with memory',
|
||||||
name: `Rowboat-win32-${arch}`,
|
name: `Rowboat-win32-${arch}`,
|
||||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -67,9 +63,7 @@ module.exports = {
|
||||||
bin: "rowboat",
|
bin: "rowboat",
|
||||||
description: 'AI coworker with memory',
|
description: 'AI coworker with memory',
|
||||||
maintainer: 'rowboatlabs',
|
maintainer: 'rowboatlabs',
|
||||||
homepage: 'https://rowboatlabs.com',
|
homepage: 'https://rowboatlabs.com'
|
||||||
icon: path.join(__dirname, 'icons/icon.png'),
|
|
||||||
mimeType: ['x-scheme-handler/rowboat'],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -80,9 +74,7 @@ module.exports = {
|
||||||
name: `Rowboat-linux`,
|
name: `Rowboat-linux`,
|
||||||
bin: "rowboat",
|
bin: "rowboat",
|
||||||
description: 'AI coworker with memory',
|
description: 'AI coworker with memory',
|
||||||
homepage: 'https://rowboatlabs.com',
|
homepage: 'https://rowboatlabs.com'
|
||||||
icon: path.join(__dirname, 'icons/icon.png'),
|
|
||||||
mimeType: ['x-scheme-handler/rowboat'],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -13,8 +13,6 @@
|
||||||
"make": "electron-forge make"
|
"make": "electron-forge make"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
|
||||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
|
||||||
"@x/core": "workspace:*",
|
"@x/core": "workspace:*",
|
||||||
"@x/shared": "workspace:*",
|
"@x/shared": "workspace:*",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
|
||||||
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
||||||
export const DEFAULT_PORT = 8080;
|
const DEFAULT_PORT = 8080;
|
||||||
export const PORT_RANGE_SIZE = 10;
|
|
||||||
|
|
||||||
/** Escape HTML special characters to prevent XSS */
|
/** Escape HTML special characters to prevent XSS */
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|
@ -20,8 +19,13 @@ export interface AuthServerResult {
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryBindPort(
|
/**
|
||||||
port: number,
|
* Create a local HTTP server to handle OAuth callback
|
||||||
|
* Listens on http://localhost:8080/oauth/callback
|
||||||
|
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
||||||
|
*/
|
||||||
|
export function createAuthServer(
|
||||||
|
port: number = DEFAULT_PORT,
|
||||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||||
): Promise<AuthServerResult> {
|
): Promise<AuthServerResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -33,7 +37,7 @@ function tryBindPort(
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url, `http://localhost:${port}`);
|
const url = new URL(req.url, `http://localhost:${port}`);
|
||||||
|
|
||||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
|
@ -92,10 +96,8 @@ function tryBindPort(
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
server.close();
|
if (err.code === 'EADDRINUSE') {
|
||||||
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
reject(new Error(`Port ${port} is already in use`));
|
||||||
// Signal caller to try next port
|
|
||||||
reject(Object.assign(new Error(err.code), { code: err.code }));
|
|
||||||
} else {
|
} else {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|
@ -103,51 +105,3 @@ function tryBindPort(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a local HTTP server to handle OAuth callback.
|
|
||||||
*
|
|
||||||
* Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
|
|
||||||
* thrown if it cannot be bound. This is the right behaviour for any provider
|
|
||||||
* whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those
|
|
||||||
* callers must keep using the exact port they've handed to the provider.
|
|
||||||
*
|
|
||||||
* Opt into `{ fallback: true }` only when the caller is prepared to use the
|
|
||||||
* port returned in `AuthServerResult` (i.e. the redirect URI is built from the
|
|
||||||
* actual bound port, not hard-coded). With fallback enabled, scans `port`
|
|
||||||
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
|
||||||
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
|
||||||
* Hyper-V/WSL2 reserve the port).
|
|
||||||
*/
|
|
||||||
export async function createAuthServer(
|
|
||||||
port: number = DEFAULT_PORT,
|
|
||||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
|
||||||
opts: { fallback?: boolean } = {},
|
|
||||||
): Promise<AuthServerResult> {
|
|
||||||
const fallback = opts.fallback === true;
|
|
||||||
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
|
||||||
|
|
||||||
for (let p = port; p <= limit; p++) {
|
|
||||||
try {
|
|
||||||
return await tryBindPort(p, onCallback);
|
|
||||||
} catch (err) {
|
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
|
||||||
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
|
||||||
console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!fallback) {
|
|
||||||
const reason = code === 'EACCES' || code === 'EADDRINUSE'
|
|
||||||
? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
|
|
||||||
: (err instanceof Error ? err.message : String(err));
|
|
||||||
throw new Error(reason);
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`No available port found in range ${port}–${limit}. Free a port in that range and try again.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unreachable — loop always returns or throws — but satisfies TypeScript
|
|
||||||
throw new Error(`No available port found in range ${port}–${limit}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 } {
|
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
composioClient.setApiKey(apiKey);
|
composioClient.setApiKey(apiKey);
|
||||||
invalidateCopilotInstructionsCache();
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -293,6 +292,20 @@ export function listConnected(): { toolkits: string[] } {
|
||||||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||||
|
*/
|
||||||
|
export async function useComposioForGoogle(): Promise<{ enabled: boolean }> {
|
||||||
|
return { enabled: await composioClient.useComposioForGoogle() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Composio should be used for Google Calendar
|
||||||
|
*/
|
||||||
|
export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> {
|
||||||
|
return { enabled: await composioClient.useComposioForGoogleCalendar() };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List available Composio toolkits — filtered to curated list only.
|
* List available Composio toolkits — filtered to curated list only.
|
||||||
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
||||||
|
|
|
||||||
|
|
@ -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 { ipc } from '@x/shared';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
listProviders,
|
listProviders,
|
||||||
} from './oauth-handler.js';
|
} from './oauth-handler.js';
|
||||||
import { watcher as watcherCore, workspace } from '@x/core';
|
import { watcher as watcherCore, workspace } from '@x/core';
|
||||||
import { WorkDir } from '@x/core/dist/config/config.js';
|
|
||||||
import { workspace as workspaceShared } from '@x/shared';
|
import { workspace as workspaceShared } from '@x/shared';
|
||||||
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
||||||
import * as runsCore from '@x/core/dist/runs/runs.js';
|
import * as runsCore from '@x/core/dist/runs/runs.js';
|
||||||
|
|
@ -31,16 +30,10 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
|
||||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
|
||||||
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
|
||||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
|
||||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||||
import * as composioHandler from './composio-handler.js';
|
import * as composioHandler from './composio-handler.js';
|
||||||
import { consumePendingDeepLink } from './deeplink.js';
|
|
||||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
|
||||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||||
|
|
@ -51,29 +44,6 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
|
||||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
|
||||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
|
||||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
|
||||||
import { API_URL } from '@x/core/dist/config/env.js';
|
|
||||||
import {
|
|
||||||
fetchLiveNote,
|
|
||||||
setLiveNote,
|
|
||||||
setLiveNoteActive,
|
|
||||||
deleteLiveNote,
|
|
||||||
listLiveNotes,
|
|
||||||
} from '@x/core/dist/knowledge/live-note/fileops.js';
|
|
||||||
import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js';
|
|
||||||
import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js';
|
|
||||||
import {
|
|
||||||
fetchTask,
|
|
||||||
patchTask,
|
|
||||||
createTask,
|
|
||||||
deleteTask,
|
|
||||||
listTasks,
|
|
||||||
readRunIds as readTaskRunIds,
|
|
||||||
} from '@x/core/dist/background-tasks/fileops.js';
|
|
||||||
import { browserIpcHandlers } from './browser/ipc.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||||
|
|
@ -140,18 +110,6 @@ function markdownToHtml(markdown: string, title: string): string {
|
||||||
</style></head><body>${html}</body></html>`
|
</style></head><body>${html}</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveShellPath(filePath: string): string {
|
|
||||||
if (filePath.startsWith('~')) {
|
|
||||||
return path.join(os.homedir(), filePath.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.isAbsolute(filePath)) {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return workspace.resolveWorkspacePath(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
type InvokeChannels = ipc.InvokeChannels;
|
type InvokeChannels = ipc.InvokeChannels;
|
||||||
type IPCChannels = ipc.IPCChannels;
|
type IPCChannels = ipc.IPCChannels;
|
||||||
|
|
||||||
|
|
@ -313,7 +271,7 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start workspace watcher
|
* Start workspace watcher
|
||||||
* Watches the configured workspace root recursively and emits change events to renderer
|
* Watches ~/.rowboat recursively and emits change events to renderer
|
||||||
*
|
*
|
||||||
* This should be called once when the app starts (from main.ts).
|
* This should be called once when the app starts (from main.ts).
|
||||||
* The watcher runs as a main-process service and catches ALL filesystem changes
|
* The watcher runs as a main-process service and catches ALL filesystem changes
|
||||||
|
|
@ -363,7 +321,7 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
|
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
|
||||||
const windows = BrowserWindow.getAllWindows();
|
const windows = BrowserWindow.getAllWindows();
|
||||||
for (const win of windows) {
|
for (const win of windows) {
|
||||||
if (!win.isDestroyed() && win.webContents) {
|
if (!win.isDestroyed() && win.webContents) {
|
||||||
|
|
@ -392,32 +350,6 @@ export async function startServicesWatcher(): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let liveNoteAgentWatcher: (() => void) | null = null;
|
|
||||||
export function startLiveNoteAgentWatcher(): void {
|
|
||||||
if (liveNoteAgentWatcher) return;
|
|
||||||
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
|
|
||||||
const windows = BrowserWindow.getAllWindows();
|
|
||||||
for (const win of windows) {
|
|
||||||
if (!win.isDestroyed() && win.webContents) {
|
|
||||||
win.webContents.send('live-note-agent:events', event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let backgroundTaskAgentWatcher: (() => void) | null = null;
|
|
||||||
export function startBackgroundTaskAgentWatcher(): void {
|
|
||||||
if (backgroundTaskAgentWatcher) return;
|
|
||||||
backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => {
|
|
||||||
const windows = BrowserWindow.getAllWindows();
|
|
||||||
for (const win of windows) {
|
|
||||||
if (!win.isDestroyed() && win.webContents) {
|
|
||||||
win.webContents.send('bg-task-agent:events', event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopRunsWatcher(): void {
|
export function stopRunsWatcher(): void {
|
||||||
if (runsWatcher) {
|
if (runsWatcher) {
|
||||||
runsWatcher();
|
runsWatcher();
|
||||||
|
|
@ -449,16 +381,6 @@ export function setupIpcHandlers() {
|
||||||
// args is null for this channel (no request payload)
|
// args is null for this channel (no request payload)
|
||||||
return getVersions();
|
return getVersions();
|
||||||
},
|
},
|
||||||
'app:consumePendingDeepLink': async () => {
|
|
||||||
return { url: consumePendingDeepLink() };
|
|
||||||
},
|
|
||||||
'analytics:bootstrap': async () => {
|
|
||||||
return {
|
|
||||||
installationId: getInstallationId(),
|
|
||||||
apiUrl: API_URL,
|
|
||||||
appVersion: app.getVersion(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
'workspace:getRoot': async () => {
|
'workspace:getRoot': async () => {
|
||||||
return workspace.getRoot();
|
return workspace.getRoot();
|
||||||
},
|
},
|
||||||
|
|
@ -489,38 +411,6 @@ export function setupIpcHandlers() {
|
||||||
'workspace:remove': async (_event, args) => {
|
'workspace:remove': async (_event, args) => {
|
||||||
return workspace.remove(args.path, args.opts);
|
return workspace.remove(args.path, args.opts);
|
||||||
},
|
},
|
||||||
'gmail:getImportant': async (_event, args) => {
|
|
||||||
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
|
|
||||||
},
|
|
||||||
'gmail:getEverythingElse': async (_event, args) => {
|
|
||||||
return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit });
|
|
||||||
},
|
|
||||||
'gmail:triggerSync': async () => {
|
|
||||||
triggerGmailSync();
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
'gmail:sendReply': async (_event, args) => {
|
|
||||||
return sendThreadReply(args);
|
|
||||||
},
|
|
||||||
'gmail:getConnectionStatus': async () => {
|
|
||||||
return getGmailConnectionStatus();
|
|
||||||
},
|
|
||||||
'gmail:getAccountEmail': async () => {
|
|
||||||
return { email: await getAccountEmail() };
|
|
||||||
},
|
|
||||||
'gmail:archiveThread': async (_event, args) => {
|
|
||||||
return archiveThread(args.threadId);
|
|
||||||
},
|
|
||||||
'gmail:trashThread': async (_event, args) => {
|
|
||||||
return trashThread(args.threadId);
|
|
||||||
},
|
|
||||||
'gmail:markThreadRead': async (_event, args) => {
|
|
||||||
return markThreadRead(args.threadId);
|
|
||||||
},
|
|
||||||
'gmail:saveMessageHeight': async (_event, args) => {
|
|
||||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
'mcp:listTools': async (_event, args) => {
|
'mcp:listTools': async (_event, args) => {
|
||||||
return mcpCore.listTools(args.serverName, args.cursor);
|
return mcpCore.listTools(args.serverName, args.cursor);
|
||||||
},
|
},
|
||||||
|
|
@ -531,17 +421,12 @@ export function setupIpcHandlers() {
|
||||||
return runsCore.createRun(args);
|
return runsCore.createRun(args);
|
||||||
},
|
},
|
||||||
'runs:createMessage': async (_event, args) => {
|
'runs:createMessage': async (_event, args) => {
|
||||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
|
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) };
|
||||||
},
|
},
|
||||||
'runs:authorizePermission': async (_event, args) => {
|
'runs:authorizePermission': async (_event, args) => {
|
||||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
'codeRun:resolvePermission': async (_event, args) => {
|
|
||||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
|
||||||
registry.resolve(args.requestId, args.decision);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
'runs:provideHumanInput': async (_event, args) => {
|
'runs:provideHumanInput': async (_event, args) => {
|
||||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
@ -560,35 +445,6 @@ export function setupIpcHandlers() {
|
||||||
await runsCore.deleteRun(args.runId);
|
await runsCore.deleteRun(args.runId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
'runs:downloadLog': async (event, args) => {
|
|
||||||
const runFileName = `${args.runId}.jsonl`;
|
|
||||||
if (path.basename(runFileName) !== runFileName) {
|
|
||||||
return { success: false, error: 'Invalid run id' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePath = path.join(WorkDir, 'runs', runFileName);
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
const result = await dialog.showSaveDialog(win!, {
|
|
||||||
defaultPath: `${runFileName}.log`,
|
|
||||||
filters: [
|
|
||||||
{ name: 'Chat Log', extensions: ['log'] },
|
|
||||||
{ name: 'JSONL', extensions: ['jsonl'] },
|
|
||||||
{ name: 'All Files', extensions: ['*'] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.copyFile(sourcePath, result.filePath);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'models:list': async () => {
|
'models:list': async () => {
|
||||||
if (await isSignedIn()) {
|
if (await isSignedIn()) {
|
||||||
return await listGatewayModels();
|
return await listGatewayModels();
|
||||||
|
|
@ -640,20 +496,6 @@ export function setupIpcHandlers() {
|
||||||
const config = await repo.getConfig();
|
const config = await repo.getConfig();
|
||||||
return { enabled: config.enabled };
|
return { enabled: config.enabled };
|
||||||
},
|
},
|
||||||
'codeMode:getConfig': async () => {
|
|
||||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
|
||||||
const config = await repo.getConfig();
|
|
||||||
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
|
|
||||||
},
|
|
||||||
'codeMode:setConfig': async (_event, args) => {
|
|
||||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
|
||||||
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
|
|
||||||
invalidateCopilotInstructionsCache();
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
'codeMode:checkAgentStatus': async () => {
|
|
||||||
return await checkCodeModeAgentStatus();
|
|
||||||
},
|
|
||||||
'granola:setConfig': async (_event, args) => {
|
'granola:setConfig': async (_event, args) => {
|
||||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||||
await repo.setConfig({ enabled: args.enabled });
|
await repo.setConfig({ enabled: args.enabled });
|
||||||
|
|
@ -724,8 +566,11 @@ export function setupIpcHandlers() {
|
||||||
'composio:list-toolkits': async () => {
|
'composio:list-toolkits': async () => {
|
||||||
return composioHandler.listToolkits();
|
return composioHandler.listToolkits();
|
||||||
},
|
},
|
||||||
'migration:check-composio-google': async () => {
|
'composio:use-composio-for-google': async () => {
|
||||||
return qualifyAndDisconnectComposioGoogle();
|
return composioHandler.useComposioForGoogle();
|
||||||
|
},
|
||||||
|
'composio:use-composio-for-google-calendar': async () => {
|
||||||
|
return composioHandler.useComposioForGoogleCalendar();
|
||||||
},
|
},
|
||||||
// Agent schedule handlers
|
// Agent schedule handlers
|
||||||
'agent-schedule:getConfig': async () => {
|
'agent-schedule:getConfig': async () => {
|
||||||
|
|
@ -762,17 +607,24 @@ export function setupIpcHandlers() {
|
||||||
},
|
},
|
||||||
// Shell integration handlers
|
// Shell integration handlers
|
||||||
'shell:openPath': async (_event, args) => {
|
'shell:openPath': async (_event, args) => {
|
||||||
const filePath = resolveShellPath(args.path);
|
let filePath = args.path;
|
||||||
|
if (filePath.startsWith('~')) {
|
||||||
|
filePath = path.join(os.homedir(), filePath.slice(1));
|
||||||
|
} else if (!path.isAbsolute(filePath)) {
|
||||||
|
// Workspace-relative path — resolve against ~/.rowboat/
|
||||||
|
filePath = path.join(os.homedir(), '.rowboat', filePath);
|
||||||
|
}
|
||||||
const error = await shell.openPath(filePath);
|
const error = await shell.openPath(filePath);
|
||||||
return { error: error || undefined };
|
return { error: error || undefined };
|
||||||
},
|
},
|
||||||
'shell:showItemInFolder': async (_event, args) => {
|
|
||||||
const filePath = resolveShellPath(args.path);
|
|
||||||
shell.showItemInFolder(filePath);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
'shell:readFileBase64': async (_event, args) => {
|
'shell:readFileBase64': async (_event, args) => {
|
||||||
const filePath = resolveShellPath(args.path);
|
let filePath = args.path;
|
||||||
|
if (filePath.startsWith('~')) {
|
||||||
|
filePath = path.join(os.homedir(), filePath.slice(1));
|
||||||
|
} else if (!path.isAbsolute(filePath)) {
|
||||||
|
// Workspace-relative path — resolve against ~/.rowboat/
|
||||||
|
filePath = path.join(os.homedir(), '.rowboat', filePath);
|
||||||
|
}
|
||||||
const stat = await fs.stat(filePath);
|
const stat = await fs.stat(filePath);
|
||||||
if (stat.size > 10 * 1024 * 1024) {
|
if (stat.size > 10 * 1024 * 1024) {
|
||||||
throw new Error('File too large (>10MB)');
|
throw new Error('File too large (>10MB)');
|
||||||
|
|
@ -791,19 +643,6 @@ export function setupIpcHandlers() {
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||||
},
|
},
|
||||||
'dialog:openDirectory': async (event, args) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir();
|
|
||||||
const result = await dialog.showOpenDialog(win!, {
|
|
||||||
title: args.title ?? 'Choose work directory',
|
|
||||||
defaultPath,
|
|
||||||
properties: ['openDirectory', 'createDirectory'],
|
|
||||||
});
|
|
||||||
if (result.canceled || result.filePaths.length === 0) {
|
|
||||||
return { path: null };
|
|
||||||
}
|
|
||||||
return { path: result.filePaths[0] ?? null };
|
|
||||||
},
|
|
||||||
// Knowledge version history handlers
|
// Knowledge version history handlers
|
||||||
'knowledge:history': async (_event, args) => {
|
'knowledge:history': async (_event, args) => {
|
||||||
const commits = await versionHistory.getFileHistory(args.path);
|
const commits = await versionHistory.getFileHistory(args.path);
|
||||||
|
|
@ -919,140 +758,9 @@ export function setupIpcHandlers() {
|
||||||
'voice:synthesize': async (_event, args) => {
|
'voice:synthesize': async (_event, args) => {
|
||||||
return voice.synthesizeSpeech(args.text);
|
return voice.synthesizeSpeech(args.text);
|
||||||
},
|
},
|
||||||
// Live-note handlers
|
|
||||||
'live-note:run': async (_event, args) => {
|
|
||||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
|
||||||
return {
|
|
||||||
success: !result.error,
|
|
||||||
runId: result.runId,
|
|
||||||
action: result.action,
|
|
||||||
summary: result.summary,
|
|
||||||
contentAfter: result.contentAfter,
|
|
||||||
error: result.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
'live-note:get': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
const live = await fetchLiveNote(args.filePath);
|
|
||||||
return { success: true, live };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'live-note:set': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
await setLiveNote(args.filePath, args.live);
|
|
||||||
const live = await fetchLiveNote(args.filePath);
|
|
||||||
return { success: true, live };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'live-note:setActive': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
await setLiveNoteActive(args.filePath, args.active);
|
|
||||||
const live = await fetchLiveNote(args.filePath);
|
|
||||||
return { success: true, live };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'live-note:delete': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
await deleteLiveNote(args.filePath);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'live-note:stop': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
const live = await fetchLiveNote(args.filePath);
|
|
||||||
if (!live?.lastRunId) {
|
|
||||||
return { success: false, error: 'No active run for this note' };
|
|
||||||
}
|
|
||||||
await runsCore.stop(live.lastRunId, false);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'live-note:listNotes': async () => {
|
|
||||||
const notes = await listLiveNotes();
|
|
||||||
return { notes };
|
|
||||||
},
|
|
||||||
// Bg-task handlers
|
|
||||||
'bg-task:run': async (_event, args) => {
|
|
||||||
const result = await runBackgroundTask(args.slug, 'manual', args.context);
|
|
||||||
return {
|
|
||||||
success: !result.error,
|
|
||||||
runId: result.runId,
|
|
||||||
summary: result.summary,
|
|
||||||
error: result.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
'bg-task:get': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
const task = await fetchTask(args.slug);
|
|
||||||
return { success: true, task };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'bg-task:patch': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
const task = await patchTask(args.slug, args.partial);
|
|
||||||
return { success: true, task };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'bg-task:create': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
const { slug } = await createTask({
|
|
||||||
name: args.name,
|
|
||||||
instructions: args.instructions,
|
|
||||||
...(args.triggers ? { triggers: args.triggers } : {}),
|
|
||||||
...(args.model ? { model: args.model } : {}),
|
|
||||||
...(args.provider ? { provider: args.provider } : {}),
|
|
||||||
});
|
|
||||||
return { success: true, slug };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'bg-task:delete': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
await deleteTask(args.slug);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'bg-task:stop': async (_event, args) => {
|
|
||||||
try {
|
|
||||||
const task = await fetchTask(args.slug);
|
|
||||||
if (!task?.lastRunId) {
|
|
||||||
return { success: false, error: 'No active run for this task' };
|
|
||||||
}
|
|
||||||
await runsCore.stop(task.lastRunId, false);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'bg-task:list': async (_event, args) => {
|
|
||||||
return listTasks(args);
|
|
||||||
},
|
|
||||||
'bg-task:listRunIds': async (_event, args) => {
|
|
||||||
const runIds = await readTaskRunIds(args.slug, args.limit);
|
|
||||||
return { runIds };
|
|
||||||
},
|
|
||||||
// Billing handler
|
// Billing handler
|
||||||
'billing:getInfo': async () => {
|
'billing:getInfo': async () => {
|
||||||
return await getBillingInfo();
|
return await getBillingInfo();
|
||||||
},
|
},
|
||||||
// Embedded browser handlers (WebContentsView + navigation)
|
|
||||||
...browserIpcHandlers,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
|
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
setupIpcHandlers,
|
setupIpcHandlers,
|
||||||
startRunsWatcher,
|
startRunsWatcher,
|
||||||
startServicesWatcher,
|
startServicesWatcher,
|
||||||
startLiveNoteAgentWatcher,
|
|
||||||
startBackgroundTaskAgentWatcher,
|
|
||||||
startWorkspaceWatcher,
|
startWorkspaceWatcher,
|
||||||
stopRunsWatcher,
|
stopRunsWatcher,
|
||||||
stopServicesWatcher,
|
stopServicesWatcher,
|
||||||
|
|
@ -24,35 +22,11 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
|
||||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
|
||||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
|
||||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
|
||||||
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
|
||||||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
|
||||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
|
||||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
|
||||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
|
||||||
|
|
||||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
import { execSync, exec, execFileSync } from "node:child_process";
|
import { execSync, exec, execFileSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
|
||||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
|
||||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
|
||||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
|
||||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
|
||||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
|
||||||
import {
|
|
||||||
DEEP_LINK_SCHEME,
|
|
||||||
dispatchUrl,
|
|
||||||
extractDeepLinkFromArgv,
|
|
||||||
setMainWindowForDeepLinks,
|
|
||||||
} from "./deeplink.js";
|
|
||||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -62,44 +36,6 @@ const __dirname = dirname(__filename);
|
||||||
// run this as early in the main process as possible
|
// run this as early in the main process as possible
|
||||||
if (started) app.quit();
|
if (started) app.quit();
|
||||||
|
|
||||||
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
|
|
||||||
// back into the existing process via the 'second-instance' event.
|
|
||||||
if (app.isPackaged && !app.requestSingleInstanceLock()) {
|
|
||||||
console.error('[Main] Another Rowboat instance is already running; exiting this process.');
|
|
||||||
app.quit();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register as the OS handler for rowboat:// URLs.
|
|
||||||
// In dev, point at the right argv so the OS can re-invoke us correctly.
|
|
||||||
if (process.defaultApp) {
|
|
||||||
if (process.argv.length >= 2) {
|
|
||||||
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [
|
|
||||||
path.resolve(process.argv[1]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-launch URL on Windows/Linux comes through argv.
|
|
||||||
{
|
|
||||||
const initialUrl = extractDeepLinkFromArgv(process.argv);
|
|
||||||
if (initialUrl) dispatchUrl(initialUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS sends URLs via 'open-url' (both first launch and while running).
|
|
||||||
app.on("open-url", (event, url) => {
|
|
||||||
event.preventDefault();
|
|
||||||
dispatchUrl(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subsequent launches on Windows/Linux land here via the single-instance lock.
|
|
||||||
app.on("second-instance", (_event, argv) => {
|
|
||||||
const url = extractDeepLinkFromArgv(argv);
|
|
||||||
if (url) dispatchUrl(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fix PATH for packaged Electron apps on macOS/Linux.
|
// Fix PATH for packaged Electron apps on macOS/Linux.
|
||||||
// Packaged apps inherit a minimal environment that doesn't include paths from
|
// Packaged apps inherit a minimal environment that doesn't include paths from
|
||||||
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
|
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
|
||||||
|
|
@ -120,9 +56,7 @@ function initializeExecutionEnvironment(): void {
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const env = JSON.parse(stdout) as Record<string, string>;
|
const env = JSON.parse(stdout) as Record<string, string>;
|
||||||
// Let the user's shell environment win for overlapping keys like PATH.
|
process.env = { ...env, ...process.env };
|
||||||
// Finder/launched GUI apps on macOS often start with a stripped PATH.
|
|
||||||
process.env = { ...process.env, ...env };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load shell environment', error);
|
console.error('Failed to load shell environment', error);
|
||||||
}
|
}
|
||||||
|
|
@ -140,29 +74,16 @@ const rendererPath = app.isPackaged
|
||||||
: path.join(__dirname, "../../../renderer/dist"); // Development
|
: path.join(__dirname, "../../../renderer/dist"); // Development
|
||||||
console.log("rendererPath", rendererPath);
|
console.log("rendererPath", rendererPath);
|
||||||
|
|
||||||
// Register custom protocol for serving built renderer files in production
|
// Register custom protocol for serving built renderer files in production.
|
||||||
// AND for serving local workspace files to the renderer (images, PDFs, video).
|
// This keeps SPA routes working when users deep link into the packaged app.
|
||||||
//
|
|
||||||
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
|
|
||||||
// app://<anything-else>/... → renderer SPA (existing behavior)
|
|
||||||
function registerAppProtocol() {
|
function registerAppProtocol() {
|
||||||
protocol.handle("app", (request) => {
|
protocol.handle("app", (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Workspace files: app://workspace/<rel-path>
|
// url.pathname starts with "/"
|
||||||
if (url.host === "workspace") {
|
|
||||||
try {
|
|
||||||
const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
|
||||||
if (!relPath) return new Response("Not Found", { status: 404 });
|
|
||||||
const absPath = resolveWorkspacePath(relPath);
|
|
||||||
return net.fetch(pathToFileURL(absPath).toString());
|
|
||||||
} catch {
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderer SPA — existing logic
|
|
||||||
let urlPath = url.pathname;
|
let urlPath = url.pathname;
|
||||||
|
|
||||||
|
// If it's "/" or a SPA route (no extension), serve index.html
|
||||||
if (urlPath === "/" || !path.extname(urlPath)) {
|
if (urlPath === "/" || !path.extname(urlPath)) {
|
||||||
urlPath = "/index.html";
|
urlPath = "/index.html";
|
||||||
}
|
}
|
||||||
|
|
@ -181,36 +102,12 @@ protocol.registerSchemesAsPrivileged([
|
||||||
supportFetchAPI: true,
|
supportFetchAPI: true,
|
||||||
corsEnabled: true,
|
corsEnabled: true,
|
||||||
allowServiceWorkers: true,
|
allowServiceWorkers: true,
|
||||||
// Required for byte-range requests so <video> seeking works.
|
// optional but often helpful:
|
||||||
stream: true,
|
// stream: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
|
|
||||||
|
|
||||||
function configureSessionPermissions(targetSession: Session): void {
|
|
||||||
targetSession.setPermissionCheckHandler((_webContents, permission) => {
|
|
||||||
return ALLOWED_SESSION_PERMISSIONS.has(permission);
|
|
||||||
});
|
|
||||||
|
|
||||||
targetSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
|
||||||
callback(ALLOWED_SESSION_PERMISSIONS.has(permission));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-approve display media requests and route system audio as loopback.
|
|
||||||
// Electron requires a video source in the callback even if we only want audio.
|
|
||||||
// We pass the first available screen source; the renderer discards the video track.
|
|
||||||
targetSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
|
||||||
const sources = await desktopCapturer.getSources({ types: ['screen'] });
|
|
||||||
if (sources.length === 0) {
|
|
||||||
callback({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback({ video: sources[0], audio: 'loopback' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
|
|
@ -221,24 +118,35 @@ function createWindow() {
|
||||||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||||
titleBarStyle: "hiddenInset",
|
titleBarStyle: "hiddenInset",
|
||||||
trafficLightPosition: { x: 12, y: 12 },
|
trafficLightPosition: { x: 12, y: 12 },
|
||||||
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
// IMPORTANT: keep Node out of renderer
|
// IMPORTANT: keep Node out of renderer
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
preload: preloadPath,
|
preload: preloadPath,
|
||||||
// Enable Chromium's built-in PDFium plugin so <iframe src="*.pdf">
|
|
||||||
// renders PDFs natively (zoom/scroll/print toolbar included).
|
|
||||||
plugins: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
configureSessionPermissions(session.defaultSession);
|
// Grant microphone and display-capture permissions
|
||||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||||
|
if (permission === 'media' || permission === 'display-capture') {
|
||||||
|
callback(true);
|
||||||
|
} else {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setMainWindowForDeepLinks(win);
|
// Auto-approve display media requests and route system audio as loopback.
|
||||||
win.on("closed", () => setMainWindowForDeepLinks(null));
|
// Electron requires a video source in the callback even if we only want audio.
|
||||||
|
// We pass the first available screen source; the renderer discards the video track.
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||||
|
const sources = await desktopCapturer.getSources({ types: ['screen'] });
|
||||||
|
if (sources.length === 0) {
|
||||||
|
callback({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback({ video: sources[0], audio: 'loopback' });
|
||||||
|
});
|
||||||
|
|
||||||
// Show window when content is ready to prevent blank screen
|
// Show window when content is ready to prevent blank screen
|
||||||
win.once("ready-to-show", () => {
|
win.once("ready-to-show", () => {
|
||||||
|
|
@ -263,10 +171,6 @@ function createWindow() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach the embedded browser pane manager to this window.
|
|
||||||
// The WebContentsView is created lazily on first `browser:setVisible`.
|
|
||||||
browserViewManager.attach(win);
|
|
||||||
|
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
win.loadURL("app://-/index.html");
|
win.loadURL("app://-/index.html");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -275,10 +179,10 @@ function createWindow() {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Register custom protocol before creating window.
|
// Register custom protocol before creating window (for production builds)
|
||||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
if (app.isPackaged) {
|
||||||
// serves workspace files via app://workspace/<rel-path> for media previews.
|
registerAppProtocol();
|
||||||
registerAppProtocol();
|
}
|
||||||
|
|
||||||
// Initialize auto-updater (only in production)
|
// Initialize auto-updater (only in production)
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
|
|
@ -307,18 +211,7 @@ app.whenReady().then(async () => {
|
||||||
// Initialize all config files before UI can access them
|
// Initialize all config files before UI can access them
|
||||||
await initConfigs();
|
await initConfigs();
|
||||||
|
|
||||||
// PostHog identify() is idempotent — call it on every startup so existing
|
|
||||||
// signed-in installs (and every cold start of v0.3.4+) get re-identified.
|
|
||||||
// Otherwise main-process events stay anonymous until the user re-signs-in.
|
|
||||||
identifyIfSignedIn().catch((error) => {
|
|
||||||
console.error('[Analytics] Failed to identify on startup:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
registerBrowserControlService(new ElectronBrowserControlService());
|
|
||||||
registerNotificationService(new ElectronNotificationService());
|
|
||||||
|
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
setupBrowserEventForwarding();
|
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
|
|
@ -335,30 +228,6 @@ app.whenReady().then(async () => {
|
||||||
// start services watcher
|
// start services watcher
|
||||||
startServicesWatcher();
|
startServicesWatcher();
|
||||||
|
|
||||||
// start live-note agent event watcher (forwards bus → renderer)
|
|
||||||
startLiveNoteAgentWatcher();
|
|
||||||
|
|
||||||
// start bg-task agent event watcher (forwards bus → renderer)
|
|
||||||
startBackgroundTaskAgentWatcher();
|
|
||||||
|
|
||||||
// start live-note scheduler (cron / window)
|
|
||||||
initLiveNoteScheduler();
|
|
||||||
|
|
||||||
// start bg-task scheduler (cron / window)
|
|
||||||
initBackgroundTaskScheduler();
|
|
||||||
|
|
||||||
// register event consumers and start the shared event processor
|
|
||||||
// (consumes $WorkDir/events/pending/, routes events to all consumers
|
|
||||||
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
|
|
||||||
registerConsumer(liveNoteEventConsumer);
|
|
||||||
registerConsumer(backgroundTaskEventConsumer);
|
|
||||||
initEventProcessor();
|
|
||||||
|
|
||||||
// If the stored Google grant predates a scope change (only old scopes),
|
|
||||||
// disconnect it now so the user re-connects with the current scopes before
|
|
||||||
// any Google sync runs against the stale grant.
|
|
||||||
await disconnectGoogleIfScopesStale();
|
|
||||||
|
|
||||||
// start gmail sync
|
// start gmail sync
|
||||||
initGmailSync();
|
initGmailSync();
|
||||||
|
|
||||||
|
|
@ -389,17 +258,9 @@ app.whenReady().then(async () => {
|
||||||
// start agent notes learning service
|
// start agent notes learning service
|
||||||
initAgentNotes();
|
initAgentNotes();
|
||||||
|
|
||||||
// start calendar meeting notification service (fires 1-minute warnings)
|
|
||||||
initCalendarNotifications();
|
|
||||||
|
|
||||||
// start chrome extension sync server
|
// start chrome extension sync server
|
||||||
initChromeSync();
|
initChromeSync();
|
||||||
|
|
||||||
// start local sites server for iframe dashboards and other mini apps
|
|
||||||
initLocalSites().catch((error) => {
|
|
||||||
console.error('[LocalSites] Failed to start:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
@ -418,16 +279,4 @@ app.on("before-quit", () => {
|
||||||
stopWorkspaceWatcher();
|
stopWorkspaceWatcher();
|
||||||
stopRunsWatcher();
|
stopRunsWatcher();
|
||||||
stopServicesWatcher();
|
stopServicesWatcher();
|
||||||
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
|
||||||
try {
|
|
||||||
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
|
||||||
} catch {
|
|
||||||
// nothing live to dispose
|
|
||||||
}
|
|
||||||
shutdownLocalSites().catch((error) => {
|
|
||||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
|
||||||
});
|
|
||||||
shutdownAnalytics().catch((error) => {
|
|
||||||
console.error('[Analytics] Failed to flush on quit:', error);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 { shell } from 'electron';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import { createAuthServer } from './auth-server.js';
|
import { createAuthServer } from './auth-server.js';
|
||||||
import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
|
|
||||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||||
|
|
@ -12,15 +11,8 @@ import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gma
|
||||||
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
||||||
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
||||||
import { emitOAuthEvent } from './ipc.js';
|
import { emitOAuthEvent } from './ipc.js';
|
||||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|
||||||
import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js';
|
|
||||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
|
||||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
|
||||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
|
||||||
|
|
||||||
function buildRedirectUri(port: number): string {
|
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||||
return `http://localhost:${port}/oauth/callback`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Top-level openid-client messages that often wrap a more specific cause. */
|
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
||||||
|
|
@ -117,15 +109,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create OAuth configuration for a provider.
|
* Get or create OAuth configuration for a provider
|
||||||
* `redirectUri` is required for DCR providers — it is the actual callback URI
|
|
||||||
* (including port) that was just bound, so the registration and auth URL stay in sync.
|
|
||||||
*/
|
*/
|
||||||
async function getProviderConfiguration(
|
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
||||||
provider: string,
|
|
||||||
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
|
|
||||||
credentialsOverride?: { clientId: string; clientSecret: string },
|
|
||||||
): Promise<Configuration> {
|
|
||||||
const config = await getProviderConfig(provider);
|
const config = await getProviderConfig(provider);
|
||||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||||
if (config.client.mode === 'static' && config.client.clientId) {
|
if (config.client.mode === 'static' && config.client.clientId) {
|
||||||
|
|
@ -157,7 +143,7 @@ async function getProviderConfiguration(
|
||||||
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
|
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
|
||||||
const clientRepo = getClientRegistrationRepo();
|
const clientRepo = getClientRegistrationRepo();
|
||||||
const existingRegistration = await clientRepo.getClientRegistration(provider);
|
const existingRegistration = await clientRepo.getClientRegistration(provider);
|
||||||
|
|
||||||
if (existingRegistration) {
|
if (existingRegistration) {
|
||||||
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
|
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
|
||||||
return await oauthClient.discoverConfiguration(
|
return await oauthClient.discoverConfiguration(
|
||||||
|
|
@ -166,21 +152,18 @@ async function getProviderConfiguration(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register new client with the actual redirect URI (port already bound)
|
// Register new client
|
||||||
const scopes = config.scopes || [];
|
const scopes = config.scopes || [];
|
||||||
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
||||||
config.discovery.issuer,
|
config.discovery.issuer,
|
||||||
[redirectUri],
|
[REDIRECT_URI],
|
||||||
scopes
|
scopes
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
|
// Save registration for future use
|
||||||
const boundPort = new URL(redirectUri).port
|
await clientRepo.saveClientRegistration(provider, registration);
|
||||||
? parseInt(new URL(redirectUri).port, 10)
|
console.log(`[OAuth] ${provider}: DCR registration saved`);
|
||||||
: DEFAULT_CALLBACK_PORT;
|
|
||||||
await clientRepo.saveClientRegistration(provider, registration, boundPort);
|
|
||||||
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
|
|
||||||
|
|
||||||
return oauthConfig;
|
return oauthConfig;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -188,7 +171,7 @@ async function getProviderConfiguration(
|
||||||
if (config.client.mode !== 'static') {
|
if (config.client.mode !== 'static') {
|
||||||
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
||||||
const { clientId, clientSecret } = await resolveClientCredentials();
|
const { clientId, clientSecret } = await resolveClientCredentials();
|
||||||
return oauthClient.createStaticConfiguration(
|
return oauthClient.createStaticConfiguration(
|
||||||
|
|
@ -201,37 +184,6 @@ async function getProviderConfiguration(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine which port to start the OAuth callback server on for a DCR provider.
|
|
||||||
*
|
|
||||||
* If the provider has an existing registration, probes the port it was registered
|
|
||||||
* on. If that port is still available, returns it so the existing client_id keeps
|
|
||||||
* working. If it is blocked, clears the stale registration (forcing re-registration
|
|
||||||
* on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base.
|
|
||||||
*
|
|
||||||
* Exported for unit testing.
|
|
||||||
*/
|
|
||||||
export async function resolveStartPort(
|
|
||||||
provider: string,
|
|
||||||
clientRepo: IClientRegistrationRepo,
|
|
||||||
): Promise<number> {
|
|
||||||
const existingReg = await clientRepo.getClientRegistration(provider);
|
|
||||||
if (!existingReg) return DEFAULT_CALLBACK_PORT;
|
|
||||||
|
|
||||||
const registeredPort = await clientRepo.getRegisteredPort(provider);
|
|
||||||
try {
|
|
||||||
// Probe — fixed-port (no fallback) so we know whether the exact registered port is free
|
|
||||||
const probe = await createAuthServer(registeredPort, () => { /* probe */ });
|
|
||||||
probe.server.close();
|
|
||||||
console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`);
|
|
||||||
return registeredPort;
|
|
||||||
} catch {
|
|
||||||
console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`);
|
|
||||||
await clientRepo.clearClientRegistration(provider);
|
|
||||||
return DEFAULT_CALLBACK_PORT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiate OAuth flow for a provider
|
* Initiate OAuth flow for a provider
|
||||||
*/
|
*/
|
||||||
|
|
@ -247,209 +199,126 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
|
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
if (!credentials?.clientId || !credentials?.clientSecret) {
|
if (!credentials?.clientId || !credentials?.clientSecret) {
|
||||||
// No credentials → rowboat mode if the user is signed in to Rowboat
|
|
||||||
// (we use the company-owned Google client via the api + webapp).
|
|
||||||
// Otherwise it's BYOK with missing creds → error.
|
|
||||||
if (await isSignedIn()) {
|
|
||||||
try {
|
|
||||||
const webappUrl = await getWebappUrl();
|
|
||||||
await shell.openExternal(`${webappUrl}/oauth/google/start`);
|
|
||||||
console.log('[OAuth] Started rowboat-mode Google connect (browser opened to webapp)');
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[OAuth] Failed to start rowboat-mode Google connect:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to open browser',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: false, error: 'Google client ID and client secret are required to connect.' };
|
return { success: false, error: 'Google client ID and client secret are required to connect.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For static-client providers (Google BYOK) the redirect URI is pre-registered
|
// Get or create OAuth configuration
|
||||||
// at the OAuth provider console on a fixed port — we must not scan.
|
const config = await getProviderConfiguration(provider, credentials);
|
||||||
// For DCR providers, resolveStartPort handles the re-registration trap.
|
|
||||||
const isStaticClient = providerConfig.client.mode === 'static';
|
|
||||||
const startPort = isStaticClient
|
|
||||||
? DEFAULT_CALLBACK_PORT
|
|
||||||
: await resolveStartPort(provider, getClientRegistrationRepo());
|
|
||||||
|
|
||||||
// --- Callback server ---
|
// Generate PKCE codes
|
||||||
// Declare `state` before the closure so the callback can close over its binding.
|
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||||
// The variable is assigned below, before shell.openExternal, so it is always
|
const state = oauthClient.generateState();
|
||||||
// set by the time any browser request arrives.
|
|
||||||
let state = '';
|
// Get scopes from config
|
||||||
|
const scopes = providerConfig.scopes || [];
|
||||||
|
|
||||||
|
// Store flow state
|
||||||
|
activeFlows.set(state, { codeVerifier, provider, config });
|
||||||
|
|
||||||
|
// Build authorization URL
|
||||||
|
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create callback server
|
||||||
let callbackHandled = false;
|
let callbackHandled = false;
|
||||||
|
const { server } = await createAuthServer(8080, async (callbackUrl) => {
|
||||||
const { server, port: boundPort } = await createAuthServer(
|
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||||
startPort,
|
if (callbackHandled) return;
|
||||||
async (callbackUrl) => {
|
callbackHandled = true;
|
||||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
const receivedState = callbackUrl.searchParams.get('state');
|
||||||
if (callbackHandled) return;
|
if (receivedState == null || receivedState === '') {
|
||||||
callbackHandled = true;
|
throw new Error(
|
||||||
const receivedState = callbackUrl.searchParams.get('state');
|
'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
|
||||||
if (receivedState == null || receivedState === '') {
|
);
|
||||||
throw new Error(
|
}
|
||||||
'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
|
if (receivedState !== state) {
|
||||||
);
|
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||||
}
|
|
||||||
if (receivedState !== state) {
|
|
||||||
throw new Error('Invalid state parameter - possible CSRF attack');
|
|
||||||
}
|
|
||||||
|
|
||||||
const flow = activeFlows.get(state);
|
|
||||||
if (!flow || flow.provider !== provider) {
|
|
||||||
throw new Error('Invalid OAuth flow state');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
|
|
||||||
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
|
||||||
const tokens = await oauthClient.exchangeCodeForTokens(
|
|
||||||
flow.config,
|
|
||||||
callbackUrl,
|
|
||||||
flow.codeVerifier,
|
|
||||||
state
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save tokens and credentials. For Google, BYOK is the only path
|
|
||||||
// that reaches this token exchange (rowboat path returns above
|
|
||||||
// before any local server runs); stamp mode: 'byok' so a future
|
|
||||||
// refresh / reconnect can't get confused with a rowboat entry.
|
|
||||||
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
|
||||||
await oauthRepo.upsert(provider, {
|
|
||||||
tokens,
|
|
||||||
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
|
||||||
...(provider === 'google' ? { mode: 'byok' as const } : {}),
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger immediate sync for relevant providers
|
|
||||||
if (provider === 'google') {
|
|
||||||
triggerGmailSync();
|
|
||||||
triggerCalendarSync();
|
|
||||||
} else if (provider === 'fireflies-ai') {
|
|
||||||
triggerFirefliesSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Rowboat sign-in, ensure user + Stripe customer exist before
|
|
||||||
// notifying the renderer. Without this, parallel API calls from
|
|
||||||
// multiple renderer hooks race to create the user, causing duplicates.
|
|
||||||
let signedInUserId: string | undefined;
|
|
||||||
if (provider === 'rowboat') {
|
|
||||||
try {
|
|
||||||
const billing = await getBillingInfo();
|
|
||||||
if (billing.userId) {
|
|
||||||
signedInUserId = billing.userId;
|
|
||||||
analyticsIdentify(billing.userId, {
|
|
||||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
|
||||||
plan: billing.subscriptionPlan,
|
|
||||||
status: billing.subscriptionStatus,
|
|
||||||
});
|
|
||||||
analyticsCapture('user_signed_in', {
|
|
||||||
plan: billing.subscriptionPlan,
|
|
||||||
status: billing.subscriptionStatus,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (meError) {
|
|
||||||
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit success event to renderer
|
|
||||||
emitOAuthEvent({
|
|
||||||
provider,
|
|
||||||
success: true,
|
|
||||||
...(signedInUserId ? { userId: signedInUserId } : {}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OAuth token exchange failed:', error);
|
|
||||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
|
||||||
let cause: unknown = error;
|
|
||||||
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
|
|
||||||
cause = (cause as { cause?: unknown }).cause;
|
|
||||||
if (cause != null) {
|
|
||||||
console.error('[OAuth] Caused by:', cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const errorMessage = getOAuthErrorMessage(error);
|
|
||||||
emitOAuthEvent({ provider, success: false, error: errorMessage });
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
// Clean up
|
|
||||||
activeFlows.delete(state);
|
|
||||||
if (activeFlow && activeFlow.state === state) {
|
|
||||||
clearTimeout(activeFlow.cleanupTimeout);
|
|
||||||
activeFlow.server.close();
|
|
||||||
activeFlow = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Static providers (Google BYOK) keep fixed-port behaviour to match the
|
|
||||||
// pre-registered redirect URI at the provider's console. DCR providers
|
|
||||||
// can fall back since we register the actual bound port below.
|
|
||||||
{ fallback: !isStaticClient },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Server is bound. Any throw between here and `activeFlow = ...` would
|
|
||||||
// leak the port — `cancelActiveFlow` only closes it once activeFlow is set.
|
|
||||||
try {
|
|
||||||
// TOCTOU guard: resolveStartPort probed the registered port and found it
|
|
||||||
// free, but the port could have been grabbed between probe and real bind,
|
|
||||||
// causing fallback to a different port. The cached client_id is registered
|
|
||||||
// for the old port — clear it so getProviderConfiguration re-registers
|
|
||||||
// with the actual bound port.
|
|
||||||
if (!isStaticClient && boundPort !== startPort) {
|
|
||||||
console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`);
|
|
||||||
await getClientRegistrationRepo().clearClientRegistration(provider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = buildRedirectUri(boundPort);
|
const flow = activeFlows.get(state);
|
||||||
const config = await getProviderConfiguration(provider, redirectUri, credentials);
|
if (!flow || flow.provider !== provider) {
|
||||||
|
throw new Error('Invalid OAuth flow state');
|
||||||
|
}
|
||||||
|
|
||||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
try {
|
||||||
state = oauthClient.generateState();
|
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
|
||||||
|
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
||||||
|
const tokens = await oauthClient.exchangeCodeForTokens(
|
||||||
|
flow.config,
|
||||||
|
callbackUrl,
|
||||||
|
flow.codeVerifier,
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
const scopes = providerConfig.scopes || [];
|
// Save tokens and credentials
|
||||||
activeFlows.set(state, { codeVerifier, provider, config });
|
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||||
|
await oauthRepo.upsert(provider, {
|
||||||
|
tokens,
|
||||||
|
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
// Trigger immediate sync for relevant providers
|
||||||
redirect_uri: redirectUri,
|
if (provider === 'google') {
|
||||||
scope: scopes.join(' '),
|
triggerGmailSync();
|
||||||
code_challenge: codeChallenge,
|
triggerCalendarSync();
|
||||||
state,
|
} else if (provider === 'fireflies-ai') {
|
||||||
});
|
triggerFirefliesSync();
|
||||||
|
|
||||||
// Set timeout to clean up abandoned flows (2 minutes)
|
|
||||||
const cleanupTimeout = setTimeout(() => {
|
|
||||||
if (activeFlow?.state === state) {
|
|
||||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
|
||||||
cancelActiveFlow('timed_out');
|
|
||||||
}
|
}
|
||||||
}, 2 * 60 * 1000);
|
|
||||||
|
|
||||||
activeFlow = {
|
// Emit success event to renderer
|
||||||
provider,
|
emitOAuthEvent({ provider, success: true });
|
||||||
state,
|
} catch (error) {
|
||||||
server,
|
console.error('OAuth token exchange failed:', error);
|
||||||
cleanupTimeout,
|
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
||||||
};
|
let cause: unknown = error;
|
||||||
|
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
|
||||||
// Open in system browser (shares cookies/sessions with user's regular browser)
|
cause = (cause as { cause?: unknown }).cause;
|
||||||
shell.openExternal(authUrl.toString());
|
if (cause != null) {
|
||||||
|
console.error('[OAuth] Caused by:', cause);
|
||||||
return { success: true };
|
}
|
||||||
} catch (setupError) {
|
}
|
||||||
// Post-bind setup failed — close the server so the port is released and
|
const errorMessage = getOAuthErrorMessage(error);
|
||||||
// a retry isn't blocked by our own zombie listener.
|
emitOAuthEvent({ provider, success: false, error: errorMessage });
|
||||||
server.close();
|
throw error;
|
||||||
if (state) {
|
} finally {
|
||||||
|
// Clean up
|
||||||
activeFlows.delete(state);
|
activeFlows.delete(state);
|
||||||
|
if (activeFlow && activeFlow.state === state) {
|
||||||
|
clearTimeout(activeFlow.cleanupTimeout);
|
||||||
|
activeFlow.server.close();
|
||||||
|
activeFlow = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw setupError;
|
});
|
||||||
}
|
|
||||||
|
// Set timeout to clean up abandoned flows (2 minutes)
|
||||||
|
// This prevents memory leaks if user never completes the OAuth flow
|
||||||
|
const cleanupTimeout = setTimeout(() => {
|
||||||
|
if (activeFlow?.state === state) {
|
||||||
|
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||||
|
cancelActiveFlow('timed_out');
|
||||||
|
}
|
||||||
|
}, 2 * 60 * 1000); // 2 minutes
|
||||||
|
|
||||||
|
// Store complete flow state for cleanup
|
||||||
|
activeFlow = {
|
||||||
|
provider,
|
||||||
|
state,
|
||||||
|
server,
|
||||||
|
cleanupTimeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open in system browser (shares cookies/sessions with user's regular browser)
|
||||||
|
shell.openExternal(authUrl.toString());
|
||||||
|
|
||||||
|
// Wait for callback (server will handle it)
|
||||||
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth connection failed:', error);
|
console.error('OAuth connection failed:', error);
|
||||||
return {
|
return {
|
||||||
|
|
@ -459,70 +328,13 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete a rowboat-mode Google connect: claim the tokens parked under
|
|
||||||
* `state` by the webapp callback, persist them locally, and trigger sync.
|
|
||||||
*
|
|
||||||
* Called by the deep-link dispatcher (deeplink.ts) when the OS hands us a
|
|
||||||
* rowboat://oauth/google/done?session=<state> URL.
|
|
||||||
*/
|
|
||||||
export async function completeRowboatGoogleConnect(state: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
console.log('[OAuth] Claiming rowboat-mode Google tokens...');
|
|
||||||
const tokens = await claimTokensViaBackend(state);
|
|
||||||
const oauthRepo = getOAuthRepo();
|
|
||||||
await oauthRepo.upsert('google', {
|
|
||||||
tokens,
|
|
||||||
mode: 'rowboat',
|
|
||||||
// Explicitly null these — no client_id/secret on the desktop in this mode.
|
|
||||||
clientId: null,
|
|
||||||
clientSecret: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
triggerGmailSync();
|
|
||||||
triggerCalendarSync();
|
|
||||||
emitOAuthEvent({ provider: 'google', success: true });
|
|
||||||
console.log('[OAuth] Rowboat-mode Google connect complete');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[OAuth] Failed to complete rowboat-mode Google connect:', error);
|
|
||||||
emitOAuthEvent({
|
|
||||||
provider: 'google',
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to claim Google tokens',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect a provider (clear tokens)
|
* Disconnect a provider (clear tokens)
|
||||||
*/
|
*/
|
||||||
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const oauthRepo = getOAuthRepo();
|
const oauthRepo = getOAuthRepo();
|
||||||
|
|
||||||
// For rowboat-mode Google, best-effort revoke at Google before clearing
|
|
||||||
// local state. Google's revoke endpoint accepts an unauthenticated POST
|
|
||||||
// with the access_token; failure is logged but doesn't block disconnect.
|
|
||||||
if (provider === 'google') {
|
|
||||||
const connection = await oauthRepo.read(provider);
|
|
||||||
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
|
|
||||||
try {
|
|
||||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
|
||||||
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
|
||||||
if (!res.ok) {
|
|
||||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[OAuth] Google revoke failed; continuing with local disconnect:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await oauthRepo.delete(provider);
|
await oauthRepo.delete(provider);
|
||||||
if (provider === 'rowboat') {
|
|
||||||
analyticsCapture('user_signed_out');
|
|
||||||
analyticsReset();
|
|
||||||
}
|
|
||||||
// Notify renderer so sidebar, voice, and billing re-check state
|
// Notify renderer so sidebar, voice, and billing re-check state
|
||||||
emitOAuthEvent({ provider, success: false });
|
emitOAuthEvent({ provider, success: false });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
@ -532,81 +344,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Startup migration for Google scope changes. When a connected Google grant was
|
|
||||||
* issued before a scope was added (e.g. old installs on gmail.readonly that
|
|
||||||
* never received gmail.modify), invalidate it so the user is prompted to
|
|
||||||
* reconnect and re-grant with the current scopes. The currently-requested
|
|
||||||
* scopes in the provider config are the source of truth: a grant missing any
|
|
||||||
* of them is treated as stale.
|
|
||||||
*
|
|
||||||
* We revoke + clear the stale token but DELIBERATELY keep the provider entry
|
|
||||||
* with an `error` set rather than calling disconnectProvider (which deletes the
|
|
||||||
* whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your
|
|
||||||
* accounts" alert and the connectors "Reconnect" row — key off this `error`
|
|
||||||
* field, not off the connected flag. A fully deleted entry has no error and is
|
|
||||||
* indistinguishable from "never connected", so no prompt would ever appear.
|
|
||||||
*
|
|
||||||
* Tokens with no recorded scopes (very old installs that never persisted them)
|
|
||||||
* are also treated as stale. Safe to call on every startup — it's a no-op once
|
|
||||||
* the grant covers all current scopes, and once invalidated the early return on
|
|
||||||
* the missing token keeps it from re-running until the user reconnects.
|
|
||||||
*/
|
|
||||||
export async function disconnectGoogleIfScopesStale(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const oauthRepo = getOAuthRepo();
|
|
||||||
const connection = await oauthRepo.read('google');
|
|
||||||
|
|
||||||
// Not connected (or already invalidated) — nothing to migrate.
|
|
||||||
if (!connection.tokens) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerConfig = await getProviderConfig('google');
|
|
||||||
const requiredScopes = providerConfig.scopes ?? [];
|
|
||||||
if (requiredScopes.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const granted = new Set(connection.tokens.scopes ?? []);
|
|
||||||
const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
|
|
||||||
if (missingScopes.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
|
|
||||||
'invalidating it so the user is prompted to reconnect with the new scopes.'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
|
|
||||||
if (connection.mode === 'rowboat' && connection.tokens.access_token) {
|
|
||||||
try {
|
|
||||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
|
||||||
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
|
||||||
if (!res.ok) {
|
|
||||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the stale token but keep the entry with an error so the reconnect
|
|
||||||
// prompt fires (see the note above).
|
|
||||||
await oauthRepo.upsert('google', {
|
|
||||||
tokens: null,
|
|
||||||
error: 'Google permissions changed. Please reconnect to continue.',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nudge any already-open window to re-read state. The renderer's initial
|
|
||||||
// mount also re-reads, so the prompt shows even if no window is up yet.
|
|
||||||
emitOAuthEvent({ provider: 'google', success: false });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[OAuth] Google scope migration check failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get access token for a provider (internal use only)
|
* Get access token for a provider (internal use only)
|
||||||
* Refreshes token if expired
|
* Refreshes token if expired
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const { id } = await runsCore.createRun({
|
const { id } = await runsCore.createRun({
|
||||||
// this expects an agent file to exist at WorkDir/agents/test-agent.md
|
// this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md
|
||||||
agentId: 'test-agent',
|
agentId: 'test-agent',
|
||||||
});
|
});
|
||||||
console.log(`created run: ${id}`);
|
console.log(`created run: ${id}`);
|
||||||
|
|
@ -16,4 +16,4 @@ async function main() {
|
||||||
console.log(`created message: ${msgId}`);
|
console.log(`created message: ${msgId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||||
import { ipc as ipcShared } from '@x/shared';
|
import { ipc as ipcShared } from '@x/shared';
|
||||||
|
|
||||||
type InvokeChannels = ipcShared.InvokeChannels;
|
type InvokeChannels = ipcShared.InvokeChannels;
|
||||||
|
|
@ -55,5 +55,4 @@ contextBridge.exposeInMainWorld('ipc', ipc);
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronUtils', {
|
contextBridge.exposeInMainWorld('electronUtils', {
|
||||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||||
getZoomFactor: () => webFrame.getZoomFactor(),
|
});
|
||||||
});
|
|
||||||
|
|
@ -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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eigenpal/docx-editor-react": "^1.0.3",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
|
@ -26,16 +25,14 @@
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "3.22.4",
|
"@tiptap/extension-image": "^3.16.0",
|
||||||
"@tiptap/extension-image": "3.22.4",
|
"@tiptap/extension-link": "^3.15.3",
|
||||||
"@tiptap/extension-link": "3.22.4",
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
"@tiptap/extension-placeholder": "3.22.4",
|
"@tiptap/extension-task-item": "^3.15.3",
|
||||||
"@tiptap/extension-table": "3.22.4",
|
"@tiptap/extension-task-list": "^3.15.3",
|
||||||
"@tiptap/extension-task-item": "3.22.4",
|
"@tiptap/pm": "^3.15.3",
|
||||||
"@tiptap/extension-task-list": "3.22.4",
|
"@tiptap/react": "^3.15.3",
|
||||||
"@tiptap/pm": "3.22.4",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"@tiptap/react": "3.22.4",
|
|
||||||
"@tiptap/starter-kit": "3.22.4",
|
|
||||||
"@x/preload": "workspace:*",
|
"@x/preload": "workspace:*",
|
||||||
"@x/shared": "workspace:*",
|
"@x/shared": "workspace:*",
|
||||||
"ai": "^5.0.117",
|
"ai": "^5.0.117",
|
||||||
|
|
@ -43,25 +40,13 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mermaid": "^11.14.0",
|
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"posthog-js": "^1.332.0",
|
"posthog-js": "^1.332.0",
|
||||||
"prosemirror-commands": "^1.7.1",
|
|
||||||
"prosemirror-dropcursor": "^1.8.2",
|
|
||||||
"prosemirror-history": "^1.5.0",
|
|
||||||
"prosemirror-keymap": "^1.2.3",
|
|
||||||
"prosemirror-model": "^1.25.7",
|
|
||||||
"prosemirror-state": "^1.4.4",
|
|
||||||
"prosemirror-tables": "^1.8.5",
|
|
||||||
"prosemirror-transform": "^1.12.0",
|
|
||||||
"prosemirror-view": "^1.41.8",
|
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-tweet": "^3.2.2",
|
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"remark-breaks": "^4.0.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"streamdown": "^1.6.10",
|
"streamdown": "^1.6.10",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|
@ -69,7 +54,6 @@
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"yaml": "^2.8.2",
|
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,6 @@ import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||||
query: string;
|
query: string;
|
||||||
options?: string[];
|
|
||||||
onResponse: (response: string) => void;
|
onResponse: (response: string) => void;
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -17,21 +16,17 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||||
export const AskHumanRequest = ({
|
export const AskHumanRequest = ({
|
||||||
className,
|
className,
|
||||||
query,
|
query,
|
||||||
options,
|
|
||||||
onResponse,
|
onResponse,
|
||||||
isProcessing = false,
|
isProcessing = false,
|
||||||
...props
|
...props
|
||||||
}: AskHumanRequestProps) => {
|
}: AskHumanRequestProps) => {
|
||||||
const [response, setResponse] = useState("");
|
const [response, setResponse] = useState("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const hasOptions = Array.isArray(options) && options.length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
|
// Auto-focus the textarea when component mounts
|
||||||
if (!hasOptions) {
|
textareaRef.current?.focus();
|
||||||
textareaRef.current?.focus();
|
}, []);
|
||||||
}
|
|
||||||
}, [hasOptions]);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const trimmed = response.trim();
|
const trimmed = response.trim();
|
||||||
|
|
@ -41,11 +36,6 @@ export const AskHumanRequest = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOptionClick = (option: string) => {
|
|
||||||
if (isProcessing) return;
|
|
||||||
onResponse(option);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -75,47 +65,30 @@ export const AskHumanRequest = ({
|
||||||
{query}
|
{query}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{hasOptions ? (
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Textarea
|
||||||
{options!.map((option) => (
|
ref={textareaRef}
|
||||||
<Button
|
value={response}
|
||||||
key={option}
|
onChange={(e) => setResponse(e.target.value)}
|
||||||
variant="outline"
|
onKeyDown={handleKeyDown}
|
||||||
size="sm"
|
placeholder="Type your response..."
|
||||||
onClick={() => handleOptionClick(option)}
|
disabled={isProcessing}
|
||||||
disabled={isProcessing}
|
rows={3}
|
||||||
className="bg-background"
|
className="resize-none"
|
||||||
>
|
/>
|
||||||
{option}
|
<div className="flex justify-end">
|
||||||
</Button>
|
<Button
|
||||||
))}
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="size-4" />
|
||||||
|
Send Response
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={response}
|
|
||||||
onChange={(e) => setResponse(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Type your response..."
|
|
||||||
disabled={isProcessing}
|
|
||||||
rows={3}
|
|
||||||
className="resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="size-4" />
|
|
||||||
Send Response
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 { isValidElement, type JSX } from 'react'
|
||||||
import { FilePathCard } from './file-path-card'
|
import { FilePathCard } from './file-path-card'
|
||||||
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
|
||||||
|
|
||||||
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||||
const { children, ...rest } = props
|
const { children, ...rest } = props
|
||||||
|
|
@ -20,17 +19,6 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||||
return <FilePathCard filePath={text} />
|
return <FilePathCard filePath={text} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
typeof childProps.className === 'string' &&
|
|
||||||
childProps.className.includes('language-mermaid')
|
|
||||||
) {
|
|
||||||
const text = typeof childProps.children === 'string'
|
|
||||||
? childProps.children.trim()
|
|
||||||
: ''
|
|
||||||
if (text) {
|
|
||||||
return <MermaidRenderer source={text} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passthrough for all other code blocks - return children directly
|
// Passthrough for all other code blocks - return children directly
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -8,10 +9,9 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||||
import { useState, type ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
export type PermissionRequestProps = ComponentProps<"div"> & {
|
export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||||
|
|
@ -22,15 +22,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||||
onDeny?: () => void;
|
onDeny?: () => void;
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
response?: 'approve' | 'deny' | null;
|
response?: 'approve' | 'deny' | null;
|
||||||
permission?: z.infer<typeof ToolPermissionMetadata>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileActionLabels: Record<string, string> = {
|
|
||||||
read: "Read file",
|
|
||||||
list: "List folder",
|
|
||||||
search: "Search files",
|
|
||||||
write: "Write files",
|
|
||||||
delete: "Delete path",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PermissionRequest = ({
|
export const PermissionRequest = ({
|
||||||
|
|
@ -42,33 +33,26 @@ export const PermissionRequest = ({
|
||||||
onDeny,
|
onDeny,
|
||||||
isProcessing = false,
|
isProcessing = false,
|
||||||
response = null,
|
response = null,
|
||||||
permission,
|
|
||||||
...props
|
...props
|
||||||
}: PermissionRequestProps) => {
|
}: PermissionRequestProps) => {
|
||||||
// Extract command from arguments if it's executeCommand
|
// Extract command from arguments if it's executeCommand
|
||||||
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
const command = toolCall.toolName === "executeCommand"
|
||||||
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||||
? String(toolCall.arguments.command)
|
? String(toolCall.arguments.command)
|
||||||
: JSON.stringify(toolCall.arguments))
|
: JSON.stringify(toolCall.arguments))
|
||||||
: null;
|
: null;
|
||||||
const filePermission = permission?.kind === "file" ? permission : null;
|
|
||||||
|
|
||||||
const isResponded = response !== null;
|
const isResponded = response !== null;
|
||||||
const isApproved = response === 'approve';
|
const isApproved = response === 'approve';
|
||||||
|
|
||||||
// Once a response is chosen, collapse the details to just the header.
|
|
||||||
// Users can click the header to expand them again.
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const showDetails = !isResponded || expanded;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"not-prose mb-4 w-full rounded-md border",
|
"not-prose mb-4 w-full rounded-md border",
|
||||||
isResponded
|
isResponded
|
||||||
? isApproved
|
? isApproved
|
||||||
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
|
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
|
||||||
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
|
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
|
||||||
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
@ -76,14 +60,17 @@ export const PermissionRequest = ({
|
||||||
>
|
>
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{!isResponded && (
|
{isResponded ? (
|
||||||
|
isApproved ? (
|
||||||
|
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
|
|
||||||
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-sm text-foreground">
|
<h3 className="font-semibold text-sm text-foreground">
|
||||||
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
||||||
|
|
@ -93,15 +80,30 @@ export const PermissionRequest = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isResponded && (
|
{isResponded && (
|
||||||
<ChevronDownIcon
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
"shrink-0",
|
||||||
expanded ? "rotate-180" : "rotate-0"
|
isApproved
|
||||||
|
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||||
|
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
{isApproved ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3 mr-1" />
|
||||||
|
Approved
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3 mr-1" />
|
||||||
|
Denied
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showDetails && command && (
|
{command && (
|
||||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||||
Command
|
Command
|
||||||
|
|
@ -111,35 +113,7 @@ export const PermissionRequest = ({
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showDetails && filePermission && (
|
{!command && toolCall.arguments && (
|
||||||
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
|
||||||
Action
|
|
||||||
</p>
|
|
||||||
<p className="text-xs font-medium text-foreground">
|
|
||||||
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
|
||||||
Path{filePermission.paths.length === 1 ? "" : "s"}
|
|
||||||
</p>
|
|
||||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
|
||||||
{filePermission.paths.join("\n")}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
|
||||||
Approval Scope
|
|
||||||
</p>
|
|
||||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
|
||||||
{filePermission.pathPrefix}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showDetails && !command && !filePermission && toolCall.arguments && (
|
|
||||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||||
Arguments
|
Arguments
|
||||||
|
|
@ -159,12 +133,12 @@ export const PermissionRequest = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onApprove}
|
onClick={onApprove}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
|
className={cn("flex-1", command && "rounded-r-none")}
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
{(command || filePermission) && (
|
{command && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,11 @@ export type FileMention = {
|
||||||
id: string;
|
id: string;
|
||||||
path: string; // "knowledge/notes.md"
|
path: string; // "knowledge/notes.md"
|
||||||
displayName: string; // "notes"
|
displayName: string; // "notes"
|
||||||
lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MentionsContext = {
|
export type MentionsContext = {
|
||||||
mentions: FileMention[];
|
mentions: FileMention[];
|
||||||
addMention: (path: string, displayName: string, lineNumber?: number) => void;
|
addMention: (path: string, displayName: string) => void;
|
||||||
removeMention: (id: string) => void;
|
removeMention: (id: string) => void;
|
||||||
clearMentions: () => void;
|
clearMentions: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -280,13 +279,13 @@ export function PromptInputProvider({
|
||||||
// ----- mentions state (for @ file mentions)
|
// ----- mentions state (for @ file mentions)
|
||||||
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
|
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
|
||||||
|
|
||||||
const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => {
|
const addMention = useCallback((path: string, displayName: string) => {
|
||||||
setMentionsList((prev) => {
|
setMentionsList((prev) => {
|
||||||
// Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct)
|
// Avoid duplicates
|
||||||
if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) {
|
if (prev.some((m) => m.path === path)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return [...prev, { id: nanoid(), path, displayName, lineNumber }];
|
return [...prev, { id: nanoid(), path, displayName }];
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ToolUIPart } from "ai";
|
import type { ToolUIPart } from "ai";
|
||||||
import {
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
CircleCheck,
|
CircleIcon,
|
||||||
LoaderIcon,
|
ClockIcon,
|
||||||
ShieldCheckIcon,
|
WrenchIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
|
||||||
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
|
||||||
|
|
||||||
const formatToolValue = (value: unknown) => {
|
const formatToolValue = (value: unknown) => {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
|
|
@ -51,68 +45,51 @@ const ToolCode = ({
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ToolAutoPermissionDetail = {
|
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||||
decision: "allow";
|
|
||||||
reason: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolProps = ComponentProps<typeof Collapsible> & {
|
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||||
autoPermissionDetail?: ToolAutoPermissionDetail;
|
<Collapsible
|
||||||
};
|
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||||
|
{...props}
|
||||||
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
|
/>
|
||||||
const toolCard = (
|
);
|
||||||
<Collapsible
|
|
||||||
className={cn(
|
|
||||||
autoPermissionDetail
|
|
||||||
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
|
||||||
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!autoPermissionDetail) return toolCard;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="not-prose mb-4 w-full">
|
|
||||||
{toolCard}
|
|
||||||
<div className="mt-1 flex justify-end px-3">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
|
|
||||||
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
|
|
||||||
Auto-approved
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="end" className="max-w-sm">
|
|
||||||
{autoPermissionDetail.reason}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolHeaderProps = {
|
export type ToolHeaderProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
type: ToolUIPart["type"];
|
type: ToolUIPart["type"];
|
||||||
state: ToolUIPart["state"];
|
state: ToolUIPart["state"];
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Hide the leading status icon (used for child rows inside a tool group). */
|
|
||||||
hideLeadIcon?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lead icon shown to the left of the tool label: spinner while running, a
|
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||||
// green check when done, a red cross on error. Shared by ToolHeader (single
|
const labels: Record<ToolUIPart["state"], string> = {
|
||||||
// tools) and the tool-call group.
|
"input-streaming": "Pending",
|
||||||
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => {
|
"input-available": "Running",
|
||||||
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />;
|
// @ts-expect-error state only available in AI SDK v6
|
||||||
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
|
"approval-requested": "Awaiting Approval",
|
||||||
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
|
"approval-responded": "Responded",
|
||||||
|
"output-available": "Completed",
|
||||||
|
"output-error": "Error",
|
||||||
|
"output-denied": "Denied",
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||||
|
"input-streaming": <CircleIcon className="size-4" />,
|
||||||
|
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||||
|
// @ts-expect-error state only available in AI SDK v6
|
||||||
|
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||||
|
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||||
|
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||||
|
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||||
|
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||||
|
{icons[status]}
|
||||||
|
{labels[status]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ToolHeader = ({
|
export const ToolHeader = ({
|
||||||
|
|
@ -120,39 +97,32 @@ export const ToolHeader = ({
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
state,
|
state,
|
||||||
hideLeadIcon,
|
|
||||||
...props
|
...props
|
||||||
}: ToolHeaderProps) => {
|
}: ToolHeaderProps) => (
|
||||||
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
return (
|
"flex w-full items-center justify-between gap-4 p-3",
|
||||||
<CollapsibleTrigger
|
className
|
||||||
className={cn(
|
)}
|
||||||
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
|
{...props}
|
||||||
className
|
>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
{...props}
|
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||||
>
|
<span className="font-medium text-sm">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
{title ?? type.split("-").slice(1).join("-")}
|
||||||
{!hideLeadIcon && getLeadIcon(state)}
|
</span>
|
||||||
<span
|
{getStatusBadge(state)}
|
||||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
</div>
|
||||||
title={displayTitle}
|
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
>
|
</CollapsibleTrigger>
|
||||||
{displayTitle}
|
);
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||||
|
|
||||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||||
<CollapsibleContent
|
<CollapsibleContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -246,97 +216,3 @@ export const ToolTabbedContent = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolGroupProps = {
|
|
||||||
group: ToolGroupType
|
|
||||||
isToolOpen: (toolId: string) => boolean
|
|
||||||
onToolOpenChange: (toolId: string, open: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => {
|
|
||||||
if (tools.some(t => t.status === 'error')) return 'output-error'
|
|
||||||
if (tools.some(t => t.status === 'running')) return 'input-available'
|
|
||||||
if (tools.some(t => t.status === 'pending')) return 'input-streaming'
|
|
||||||
return 'output-available'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const state = getGroupState(group.items)
|
|
||||||
const isCompleted = state === 'output-available' || state === 'output-error'
|
|
||||||
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
|
||||||
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
|
||||||
const toolCount = group.items.length
|
|
||||||
const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}`
|
|
||||||
const actions = isCompleted ? getToolActionsSummary(group.items) : ''
|
|
||||||
// Plain string used as the AnimatePresence key + tooltip; the rendered node
|
|
||||||
// shows the action summary in a lighter gray than the "Ran N tools" prefix.
|
|
||||||
const summaryText = isCompleted
|
|
||||||
? `${ranLabel} · ${actions}`
|
|
||||||
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
|
|
||||||
const summaryNode: ReactNode = isCompleted
|
|
||||||
? <>{ranLabel} <span className="font-normal text-muted-foreground">{`· ${actions}`}</span></>
|
|
||||||
: summaryText
|
|
||||||
|
|
||||||
const leadIcon = getLeadIcon(state)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
{leadIcon}
|
|
||||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
|
||||||
<motion.span
|
|
||||||
key={summaryText}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
||||||
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
|
||||||
title={summaryText}
|
|
||||||
>
|
|
||||||
{summaryNode}
|
|
||||||
</motion.span>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
|
||||||
<div className="flex flex-col gap-2 p-2">
|
|
||||||
{group.items.map((tool) => {
|
|
||||||
const toolState = toToolState(tool.status)
|
|
||||||
const isOpen = isToolOpen(tool.id)
|
|
||||||
return (
|
|
||||||
<Tool
|
|
||||||
key={tool.id}
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
|
||||||
className="mb-0 rounded-[20px] border-border/60 bg-transparent hover:border-border/60"
|
|
||||||
>
|
|
||||||
<ToolHeader
|
|
||||||
title={getToolDisplayName(tool)}
|
|
||||||
type={`tool-${tool.name}`}
|
|
||||||
state={toolState}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
hideLeadIcon
|
|
||||||
/>
|
|
||||||
<ToolContent>
|
|
||||||
<ToolTabbedContent
|
|
||||||
input={tool.input as ToolUIPart["input"]}
|
|
||||||
output={tool.result as ToolUIPart["output"]}
|
|
||||||
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
|
||||||
/>
|
|
||||||
</ToolContent>
|
|
||||||
</Tool>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,12 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
|
|
||||||
interface WebSearchResultProps {
|
interface WebSearchResultProps {
|
||||||
query: string;
|
query: string;
|
||||||
|
|
@ -21,219 +19,39 @@ interface WebSearchResultProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long each fetched website stays on the rolling header before the
|
|
||||||
// next one slides in. Kept slow enough to read the domain + title.
|
|
||||||
const ROLL_INTERVAL_MS = 700;
|
|
||||||
|
|
||||||
// How many favicons to show in the settled stack before the rest collapse
|
|
||||||
// into a "+N" chip. The text names this many domains too, so the chip count
|
|
||||||
// (total - MAX_STACK) lines up with the "and N others" in the summary.
|
|
||||||
const MAX_STACK = 3;
|
|
||||||
|
|
||||||
function getDomain(url: string): string {
|
function getDomain(url: string): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname.replace(/^www\./, "");
|
return new URL(url).hostname;
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function faviconUrl(domain: string, size = 32): string {
|
|
||||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse the result list into unique domains, preserving order.
|
|
||||||
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const result of results) {
|
|
||||||
const domain = getDomain(result.url);
|
|
||||||
if (seen.has(domain)) continue;
|
|
||||||
seen.add(domain);
|
|
||||||
out.push(domain);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary with text hierarchy: "Searched" + "and N others" are secondary
|
|
||||||
// weight/color, the domain names are primary text at medium weight.
|
|
||||||
function buildSearchedSummary(domains: string[]): React.ReactNode {
|
|
||||||
const muted = "font-normal text-muted-foreground";
|
|
||||||
const name = (d: string) => <span className="font-medium text-foreground">{d}</span>;
|
|
||||||
if (domains.length === 1) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={muted}>Searched </span>
|
|
||||||
{name(domains[0])}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (domains.length === 2) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={muted}>Searched </span>
|
|
||||||
{name(domains[0])}
|
|
||||||
<span className={muted}> and </span>
|
|
||||||
{name(domains[1])}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const others = domains.length - 2;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={muted}>Searched </span>
|
|
||||||
{name(domains[0])}
|
|
||||||
<span className={muted}>, </span>
|
|
||||||
{name(domains[1])}
|
|
||||||
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RollPhase = "searching" | "rolling" | "settled";
|
|
||||||
|
|
||||||
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
||||||
const isRunning = status === "pending" || status === "running";
|
const isRunning = status === "pending" || status === "running";
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const domains = useMemo(() => uniqueDomains(results), [results]);
|
|
||||||
|
|
||||||
// Drive the one-shot rolling reveal. Results arrive all at once, so we
|
|
||||||
// simulate "fetching one site at a time" by stepping through them with the
|
|
||||||
// same slide animation the tool group uses, then settle on a summary.
|
|
||||||
// `settled` is seeded from the initial status so a card loaded already-
|
|
||||||
// complete from history skips straight to the summary (no roll).
|
|
||||||
const [settled, setSettled] = useState(() => !isRunning);
|
|
||||||
const [rollIndex, setRollIndex] = useState(0);
|
|
||||||
|
|
||||||
// Phase is fully derived: searching while the tool runs, rolling once
|
|
||||||
// results land, then settled. No setState-in-effect needed for transitions.
|
|
||||||
const phase: RollPhase = isRunning
|
|
||||||
? "searching"
|
|
||||||
: !settled && results.length > 0
|
|
||||||
? "rolling"
|
|
||||||
: "settled";
|
|
||||||
|
|
||||||
// Warm the browser cache for every favicon the moment results arrive, so
|
|
||||||
// each icon is already loaded by the time its row rolls in (~700ms each).
|
|
||||||
// Without this the network fetch lags the text and rows flash icon-less.
|
|
||||||
useEffect(() => {
|
|
||||||
for (const result of results) {
|
|
||||||
const img = new Image();
|
|
||||||
img.src = faviconUrl(getDomain(result.url));
|
|
||||||
}
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
// Advance the roll, then settle after the last site has had its moment.
|
|
||||||
// setState only fires inside the timeout callback, never synchronously.
|
|
||||||
useEffect(() => {
|
|
||||||
if (phase !== "rolling") return;
|
|
||||||
const isLast = rollIndex >= results.length - 1;
|
|
||||||
const timer = setTimeout(
|
|
||||||
() => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)),
|
|
||||||
ROLL_INTERVAL_MS,
|
|
||||||
);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [phase, rollIndex, results.length]);
|
|
||||||
|
|
||||||
// Build the content for the compact (collapsed) header line. Each distinct
|
|
||||||
// value gets a unique key so AnimatePresence runs the slide transition.
|
|
||||||
let headerKey: string;
|
|
||||||
let headerContent: React.ReactNode;
|
|
||||||
if (phase === "searching") {
|
|
||||||
headerKey = "searching";
|
|
||||||
headerContent = (
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
|
|
||||||
<LoaderIcon className="size-4 shrink-0 animate-spin" />
|
|
||||||
<span className="truncate">Searching the web…</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 (
|
return (
|
||||||
<Collapsible
|
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
|
||||||
open={open}
|
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||||
onOpenChange={setOpen}
|
<div className="flex items-center gap-2">
|
||||||
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||||
>
|
<span className="font-medium text-sm">{title}</span>
|
||||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
|
||||||
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
|
|
||||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
|
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
|
||||||
<motion.span
|
|
||||||
key={headerKey}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
|
||||||
className="absolute inset-0 flex items-center text-left font-medium text-sm"
|
|
||||||
>
|
|
||||||
{headerContent}
|
|
||||||
</motion.span>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{phase === "settled" && domains.length > 0 && (
|
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
|
||||||
{domains.length} source{domains.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
<CollapsibleContent>
|
||||||
<div className="px-4 pb-3 space-y-3">
|
<div className="px-3 pb-3 space-y-3">
|
||||||
{/* Query */}
|
{/* Query + result count */}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<GlobeIcon className="size-3.5 shrink-0" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||||
<span className="truncate">{query}</span>
|
<GlobeIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{query}</span>
|
||||||
|
</div>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results list */}
|
{/* Results list */}
|
||||||
|
|
@ -255,7 +73,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={faviconUrl(domain)}
|
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
@ -270,13 +88,20 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status — only while the search is still running. */}
|
{/* Status */}
|
||||||
{isRunning && (
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
{isRunning ? (
|
||||||
<LoaderIcon className="size-3.5 animate-spin" />
|
<>
|
||||||
<span>Searching...</span>
|
<LoaderIcon className="size-3.5 animate-spin" />
|
||||||
</div>
|
<span>Searching...</span>
|
||||||
)}
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||||
|
<span>Done</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
|
||||||
|
|
@ -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 * as React from 'react'
|
||||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, FolderOpen, Pencil, Trash2 } from 'lucide-react'
|
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||||
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
||||||
|
|
@ -103,18 +103,9 @@ type BasesViewProps = {
|
||||||
rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
remove: (path: string) => Promise<void>
|
remove: (path: string) => Promise<void>
|
||||||
copyPath: (path: string) => void
|
copyPath: (path: string) => void
|
||||||
revealInFileManager: (path: string, isDir: boolean) => void
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileManagerName(): string {
|
|
||||||
if (typeof navigator === 'undefined') return 'File Manager'
|
|
||||||
const platform = navigator.platform.toLowerCase()
|
|
||||||
if (platform.includes('mac')) return 'Finder'
|
|
||||||
if (platform.includes('win')) return 'Explorer'
|
|
||||||
return 'File Manager'
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||||
return nodes.flatMap((n) =>
|
return nodes.flatMap((n) =>
|
||||||
n.kind === 'file' && n.name.endsWith('.md')
|
n.kind === 'file' && n.name.endsWith('.md')
|
||||||
|
|
@ -928,10 +919,6 @@ function NoteRow({
|
||||||
<Copy className="mr-2 size-4" />
|
<Copy className="mr-2 size-4" />
|
||||||
Copy Path
|
Copy Path
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => actions?.revealInFileManager(note.path, false)}>
|
|
||||||
<FolderOpen className="mr-2 size-4" />
|
|
||||||
Open in {getFileManagerName()}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||||
<Pencil className="mr-2 size-4" />
|
<Pencil className="mr-2 size-4" />
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
FileSpreadsheet,
|
||||||
FileText,
|
FileText,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
FolderCheck,
|
|
||||||
FolderClock,
|
|
||||||
FolderCog,
|
|
||||||
FolderOpen,
|
|
||||||
Globe,
|
Globe,
|
||||||
Headphones,
|
Headphones,
|
||||||
ImagePlus,
|
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Mic,
|
Mic,
|
||||||
Plus,
|
Plus,
|
||||||
ShieldCheck,
|
|
||||||
Square,
|
Square,
|
||||||
Terminal,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
|
@ -30,12 +23,8 @@ import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
|
|
@ -67,12 +56,6 @@ export type StagedAttachment = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
|
|
||||||
const MAX_STORED_RECENT_WORK_DIRS = 8
|
|
||||||
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
|
|
||||||
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
|
|
||||||
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
|
||||||
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
|
||||||
|
|
||||||
|
|
||||||
const providerDisplayNames: Record<string, string> = {
|
const providerDisplayNames: Record<string, string> = {
|
||||||
|
|
@ -86,27 +69,13 @@ const providerDisplayNames: Record<string, string> = {
|
||||||
rowboat: 'Rowboat',
|
rowboat: 'Rowboat',
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
|
||||||
|
|
||||||
interface ConfiguredModel {
|
interface ConfiguredModel {
|
||||||
provider: ProviderName
|
flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||||
model: string
|
model: string
|
||||||
}
|
apiKey?: string
|
||||||
|
baseURL?: string
|
||||||
type RecentWorkDir = {
|
headers?: Record<string, string>
|
||||||
path: string
|
knowledgeGraphModel?: string
|
||||||
lastUsedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectedModel {
|
|
||||||
provider: string
|
|
||||||
model: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PermissionMode = 'manual' | 'auto'
|
|
||||||
|
|
||||||
function getSelectedModelDisplayName(model: string) {
|
|
||||||
return model.split('/').pop() || model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
|
|
@ -128,86 +97,8 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const path = value.trim()
|
|
||||||
return path ? { path, lastUsedAt: 0 } : null
|
|
||||||
}
|
|
||||||
if (!value || typeof value !== 'object') return null
|
|
||||||
const entry = value as Record<string, unknown>
|
|
||||||
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
|
|
||||||
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
|
|
||||||
? entry.lastUsedAt
|
|
||||||
: 0
|
|
||||||
return path ? { path, lastUsedAt } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
|
|
||||||
try {
|
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
|
|
||||||
const parsed = JSON.parse(result.data)
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const dirs: RecentWorkDir[] = []
|
|
||||||
for (const value of parsed) {
|
|
||||||
const entry = normalizeRecentWorkDir(value)
|
|
||||||
if (!entry || seen.has(entry.path)) continue
|
|
||||||
seen.add(entry.path)
|
|
||||||
dirs.push(entry)
|
|
||||||
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
|
|
||||||
}
|
|
||||||
return dirs
|
|
||||||
} catch {
|
|
||||||
// File missing or invalid — no recents yet.
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
|
|
||||||
try {
|
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
|
||||||
path: RECENT_WORK_DIRS_CONFIG_PATH,
|
|
||||||
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to persist recent work directories', err)
|
|
||||||
}
|
|
||||||
// Notify other mounted chat inputs in this window to re-read.
|
|
||||||
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRecentWorkDirTime(lastUsedAt: number) {
|
|
||||||
if (!lastUsedAt) return ''
|
|
||||||
const now = Date.now()
|
|
||||||
const diffMs = Math.max(0, now - lastUsedAt)
|
|
||||||
const minute = 60 * 1000
|
|
||||||
const hour = 60 * minute
|
|
||||||
const day = 24 * hour
|
|
||||||
if (diffMs < minute) return 'now'
|
|
||||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
|
|
||||||
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
|
|
||||||
|
|
||||||
const used = new Date(lastUsedAt)
|
|
||||||
const yesterday = new Date(now - day)
|
|
||||||
if (
|
|
||||||
used.getFullYear() === yesterday.getFullYear() &&
|
|
||||||
used.getMonth() === yesterday.getMonth() &&
|
|
||||||
used.getDate() === yesterday.getDate()
|
|
||||||
) {
|
|
||||||
return 'Yesterday'
|
|
||||||
}
|
|
||||||
if (diffMs < 7 * day) {
|
|
||||||
return used.toLocaleDateString(undefined, { weekday: 'short' })
|
|
||||||
}
|
|
||||||
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactWorkDirPath(path: string) {
|
|
||||||
return path.replace(/^\/Users\/[^/]+/, '~')
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatInputInnerProps {
|
interface ChatInputInnerProps {
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
@ -229,12 +120,6 @@ interface ChatInputInnerProps {
|
||||||
ttsMode?: 'summary' | 'full'
|
ttsMode?: 'summary' | 'full'
|
||||||
onToggleTts?: () => void
|
onToggleTts?: () => void
|
||||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
|
||||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
|
||||||
/** Work directory for this chat (per-chat). Null when none is set. */
|
|
||||||
workDir?: string | null
|
|
||||||
/** Fired when the user sets/changes/clears the work directory for this chat. */
|
|
||||||
onWorkDirChange?: (value: string | null) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatInputInner({
|
function ChatInputInner({
|
||||||
|
|
@ -260,9 +145,6 @@ function ChatInputInner({
|
||||||
ttsMode,
|
ttsMode,
|
||||||
onToggleTts,
|
onToggleTts,
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onSelectedModelChange,
|
|
||||||
workDir = null,
|
|
||||||
onWorkDirChange,
|
|
||||||
}: ChatInputInnerProps) {
|
}: ChatInputInnerProps) {
|
||||||
const controller = usePromptInputController()
|
const controller = usePromptInputController()
|
||||||
const message = controller.textInput.value
|
const message = controller.textInput.value
|
||||||
|
|
@ -273,42 +155,9 @@ function ChatInputInner({
|
||||||
|
|
||||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||||
const [activeModelKey, setActiveModelKey] = useState('')
|
const [activeModelKey, setActiveModelKey] = useState('')
|
||||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
|
||||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
|
||||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
|
||||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
|
||||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
|
||||||
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
|
|
||||||
|
|
||||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!runId) {
|
|
||||||
setLockedModel(null)
|
|
||||||
setPermissionMode('auto')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
|
||||||
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
|
||||||
if (cancelled) return
|
|
||||||
if (run.provider && run.model) {
|
|
||||||
setLockedModel({ provider: run.provider, model: run.model })
|
|
||||||
}
|
|
||||||
setPermissionMode(run.permissionMode ?? 'manual')
|
|
||||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [runId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
|
|
||||||
syncRecentWorkDirs()
|
|
||||||
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Check Rowboat sign-in state
|
// Check Rowboat sign-in state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -327,20 +176,42 @@ function ChatInputInner({
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load the list of models the user can choose from.
|
// Load model config (gateway when signed in, local config when BYOK)
|
||||||
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
|
|
||||||
const loadModelConfig = useCallback(async () => {
|
const loadModelConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isRowboatConnected) {
|
if (isRowboatConnected) {
|
||||||
|
// Fetch gateway models
|
||||||
const listResult = await window.ipc.invoke('models:list', null)
|
const listResult = await window.ipc.invoke('models:list', null)
|
||||||
const rowboatProvider = listResult.providers?.find(
|
const rowboatProvider = listResult.providers?.find(
|
||||||
(p: { id: string }) => p.id === 'rowboat'
|
(p: { id: string }) => p.id === 'rowboat'
|
||||||
)
|
)
|
||||||
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
||||||
(m: { id: string }) => ({ provider: 'rowboat', model: m.id })
|
(m: { id: string }) => ({ flavor: 'rowboat', model: m.id })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Read current default from config
|
||||||
|
let defaultModel = ''
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||||
|
const parsed = JSON.parse(result.data)
|
||||||
|
defaultModel = parsed?.model || ''
|
||||||
|
} catch { /* no config yet */ }
|
||||||
|
|
||||||
|
if (defaultModel) {
|
||||||
|
models.sort((a, b) => {
|
||||||
|
if (a.model === defaultModel) return -1
|
||||||
|
if (b.model === defaultModel) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setConfiguredModels(models)
|
setConfiguredModels(models)
|
||||||
|
const activeKey = defaultModel
|
||||||
|
? `rowboat/${defaultModel}`
|
||||||
|
: models[0] ? `rowboat/${models[0].model}` : ''
|
||||||
|
if (activeKey) setActiveModelKey(activeKey)
|
||||||
} else {
|
} else {
|
||||||
|
// BYOK: read from local models.json
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||||
const parsed = JSON.parse(result.data)
|
const parsed = JSON.parse(result.data)
|
||||||
const models: ConfiguredModel[] = []
|
const models: ConfiguredModel[] = []
|
||||||
|
|
@ -352,12 +223,32 @@ function ChatInputInner({
|
||||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||||
for (const model of allModels) {
|
for (const model of allModels) {
|
||||||
if (model) {
|
if (model) {
|
||||||
models.push({ provider: flavor as ProviderName, model })
|
models.push({
|
||||||
|
flavor: flavor as ConfiguredModel['flavor'],
|
||||||
|
model,
|
||||||
|
apiKey: (e.apiKey as string) || undefined,
|
||||||
|
baseURL: (e.baseURL as string) || undefined,
|
||||||
|
headers: (e.headers as Record<string, string>) || undefined,
|
||||||
|
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||||
|
? `${parsed.provider.flavor}/${parsed.model}`
|
||||||
|
: ''
|
||||||
|
models.sort((a, b) => {
|
||||||
|
const aKey = `${a.flavor}/${a.model}`
|
||||||
|
const bKey = `${b.flavor}/${b.model}`
|
||||||
|
if (aKey === defaultKey) return -1
|
||||||
|
if (bKey === defaultKey) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
setConfiguredModels(models)
|
setConfiguredModels(models)
|
||||||
|
if (defaultKey) {
|
||||||
|
setActiveModelKey(defaultKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No config yet
|
// No config yet
|
||||||
|
|
@ -375,147 +266,6 @@ function ChatInputInner({
|
||||||
return () => window.removeEventListener('models-config-changed', handler)
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
}, [loadModelConfig])
|
}, [loadModelConfig])
|
||||||
|
|
||||||
// Load the global code-mode feature flag (from settings) and stay in sync.
|
|
||||||
useEffect(() => {
|
|
||||||
const load = () => {
|
|
||||||
window.ipc.invoke('codeMode:getConfig', null)
|
|
||||||
.then((r) => setCodeModeFeatureEnabled(r.enabled))
|
|
||||||
.catch(() => setCodeModeFeatureEnabled(false))
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
window.addEventListener('code-mode-config-changed', load)
|
|
||||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// If the feature is turned off in settings, also turn off any per-conversation chip.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!codeModeFeatureEnabled && codeModeEnabled) {
|
|
||||||
setCodeModeEnabled(false)
|
|
||||||
}
|
|
||||||
}, [codeModeFeatureEnabled, codeModeEnabled])
|
|
||||||
|
|
||||||
|
|
||||||
// Cross-platform basename — handles both / and \ separators.
|
|
||||||
const basename = useCallback((p: string): string => {
|
|
||||||
const trimmed = p.replace(/[\\/]+$/, '')
|
|
||||||
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
||||||
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const rememberWorkDir = useCallback(async (dir: string) => {
|
|
||||||
const trimmed = dir.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
const next = [
|
|
||||||
{ path: trimmed, lastUsedAt: Date.now() },
|
|
||||||
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
|
|
||||||
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
|
|
||||||
setRecentWorkDirs(next)
|
|
||||||
await writeRecentWorkDirs(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Load coding-agent preference for a given workdir.
|
|
||||||
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
|
|
||||||
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
|
|
||||||
if (!dir) return 'claude'
|
|
||||||
try {
|
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
|
||||||
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
|
||||||
const value = parsed?.[dir]
|
|
||||||
if (value === 'codex' || value === 'claude') return value
|
|
||||||
} catch {
|
|
||||||
/* file missing or invalid — fall through to default */
|
|
||||||
}
|
|
||||||
return 'claude'
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
|
|
||||||
const existing: Record<string, 'claude' | 'codex'> = {}
|
|
||||||
try {
|
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
|
||||||
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
|
||||||
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
||||||
if (v === 'claude' || v === 'codex') existing[k] = v
|
|
||||||
}
|
|
||||||
} catch { /* start fresh */ }
|
|
||||||
existing[dir] = agent
|
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
|
||||||
path: 'config/coding-agents.json',
|
|
||||||
data: JSON.stringify(existing, null, 2),
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Work directory is owned per-chat by the parent (App). This component only
|
|
||||||
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
|
|
||||||
// the work directory changes, load its persisted coding-agent preference.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
loadCodingAgentFor(workDir).then((agent) => {
|
|
||||||
if (!cancelled) setCodingAgent(agent)
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [workDir, loadCodingAgentFor])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isActive && workDir) void rememberWorkDir(workDir)
|
|
||||||
}, [isActive, workDir, rememberWorkDir])
|
|
||||||
|
|
||||||
const handleSetWorkDir = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
let defaultPath: string | undefined = workDir ?? undefined
|
|
||||||
try {
|
|
||||||
const { root } = await window.ipc.invoke('workspace:getRoot', null)
|
|
||||||
const workspaceRel = 'knowledge/Workspace'
|
|
||||||
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
|
|
||||||
if (!exists.exists) {
|
|
||||||
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
|
|
||||||
}
|
|
||||||
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
|
|
||||||
}
|
|
||||||
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
|
|
||||||
title: 'Choose work directory',
|
|
||||||
defaultPath,
|
|
||||||
})
|
|
||||||
if (!chosen) return
|
|
||||||
onWorkDirChange?.(chosen)
|
|
||||||
await rememberWorkDir(chosen)
|
|
||||||
setCodingAgent(await loadCodingAgentFor(chosen))
|
|
||||||
toast.success(`Work directory set: ${chosen}`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set work directory', err)
|
|
||||||
toast.error('Failed to set work directory')
|
|
||||||
}
|
|
||||||
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
|
||||||
|
|
||||||
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
|
|
||||||
onWorkDirChange?.(dir)
|
|
||||||
await rememberWorkDir(dir)
|
|
||||||
setCodingAgent(await loadCodingAgentFor(dir))
|
|
||||||
toast.success(`Work directory set: ${dir}`)
|
|
||||||
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
|
||||||
|
|
||||||
const handleClearWorkDir = useCallback(() => {
|
|
||||||
onWorkDirChange?.(null)
|
|
||||||
setCodingAgent('claude')
|
|
||||||
toast.success('Work directory cleared')
|
|
||||||
}, [onWorkDirChange])
|
|
||||||
|
|
||||||
const handleToggleCodingAgent = useCallback(async () => {
|
|
||||||
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
|
|
||||||
setCodingAgent(next)
|
|
||||||
// Persist only when scoped to a workdir; without one there's nothing to key on.
|
|
||||||
if (!workDir) return
|
|
||||||
try {
|
|
||||||
await persistCodingAgent(workDir, next)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save coding agent', err)
|
|
||||||
toast.error('Failed to save coding agent')
|
|
||||||
// revert on failure
|
|
||||||
setCodingAgent(codingAgent)
|
|
||||||
}
|
|
||||||
}, [workDir, codingAgent, persistCodingAgent])
|
|
||||||
|
|
||||||
// Check search tool availability (exa or signed-in via gateway)
|
// Check search tool availability (exa or signed-in via gateway)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSearch = async () => {
|
const checkSearch = async () => {
|
||||||
|
|
@ -534,15 +284,40 @@ function ChatInputInner({
|
||||||
checkSearch()
|
checkSearch()
|
||||||
}, [isActive, isRowboatConnected])
|
}, [isActive, isRowboatConnected])
|
||||||
|
|
||||||
// Selecting a model affects only the *next* run created from this tab.
|
const handleModelChange = useCallback(async (key: string) => {
|
||||||
// Once a run exists, model is frozen on the run and the dropdown is read-only.
|
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
|
||||||
const handleModelChange = useCallback((key: string) => {
|
|
||||||
if (lockedModel) return
|
|
||||||
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
|
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
setActiveModelKey(key)
|
setActiveModelKey(key)
|
||||||
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
|
|
||||||
}, [configuredModels, lockedModel, onSelectedModelChange])
|
try {
|
||||||
|
if (entry.flavor === 'rowboat') {
|
||||||
|
// Gateway model — save with valid Zod flavor, no credentials
|
||||||
|
await window.ipc.invoke('models:saveConfig', {
|
||||||
|
provider: { flavor: 'openrouter' as const },
|
||||||
|
model: entry.model,
|
||||||
|
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// BYOK — preserve full provider config
|
||||||
|
const providerModels = configuredModels
|
||||||
|
.filter((m) => m.flavor === entry.flavor)
|
||||||
|
.map((m) => m.model)
|
||||||
|
await window.ipc.invoke('models:saveConfig', {
|
||||||
|
provider: {
|
||||||
|
flavor: entry.flavor,
|
||||||
|
apiKey: entry.apiKey,
|
||||||
|
baseURL: entry.baseURL,
|
||||||
|
headers: entry.headers,
|
||||||
|
},
|
||||||
|
model: entry.model,
|
||||||
|
models: providerModels,
|
||||||
|
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to switch model')
|
||||||
|
}
|
||||||
|
}, [configuredModels])
|
||||||
|
|
||||||
// Restore the tab draft when this input mounts.
|
// Restore the tab draft when this input mounts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -600,15 +375,12 @@ function ChatInputInner({
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
// codeMode is sticky per conversation — don't reset after send.
|
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
|
||||||
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
|
|
||||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
|
|
||||||
controller.textInput.clear()
|
controller.textInput.clear()
|
||||||
controller.mentions.clearMentions()
|
controller.mentions.clearMentions()
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
// Web search toggle stays on for the rest of the chat session; the user
|
setSearchEnabled(false)
|
||||||
// turns it off explicitly. (Not persisted across app restarts.)
|
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
|
||||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -647,14 +419,8 @@ function ChatInputInner({
|
||||||
}
|
}
|
||||||
}, [addFiles, isActive])
|
}, [addFiles, isActive])
|
||||||
|
|
||||||
const visibleRecentWorkDirs = recentWorkDirs
|
|
||||||
.filter((entry) => entry.path !== workDir)
|
|
||||||
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
|
|
||||||
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
|
|
||||||
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||||
{attachments.map((attachment) => {
|
{attachments.map((attachment) => {
|
||||||
|
|
@ -758,246 +524,38 @@ function ChatInputInner({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 pb-3">
|
<div className="flex items-center gap-2 px-4 pb-3">
|
||||||
<DropdownMenu>
|
<button
|
||||||
<Tooltip>
|
type="button"
|
||||||
<TooltipTrigger asChild>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
<DropdownMenuTrigger asChild>
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
<button
|
aria-label="Attach files"
|
||||||
type="button"
|
>
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
<Plus className="h-4 w-4" />
|
||||||
aria-label="Add"
|
</button>
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
|
|
||||||
<div className="rounded-[14px] border border-border/80 bg-background p-1">
|
|
||||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5">
|
|
||||||
<ImagePlus className="size-4" />
|
|
||||||
<span>Add files or photos</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{/* Working directory lives behind a submenu so the main menu stays to two
|
|
||||||
items. One hover/click away for power users; out of the way otherwise. */}
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
|
|
||||||
<FolderCog className="size-4" />
|
|
||||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
|
||||||
<span>Set working directory</span>
|
|
||||||
<span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground">
|
|
||||||
{currentWorkDirLabel}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
|
|
||||||
{/* Current selection — shown for context only when one is set. */}
|
|
||||||
{workDir && (
|
|
||||||
<div
|
|
||||||
title={workDir}
|
|
||||||
className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
|
|
||||||
>
|
|
||||||
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
|
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
|
|
||||||
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
|
|
||||||
{currentWorkDirPath}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Primary action: choose when unset, change when set. Always on top. */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => { void handleSetWorkDir() }}
|
|
||||||
className="h-9 rounded-[9px] px-2.5"
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4" />
|
|
||||||
<span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{visibleRecentWorkDirs.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Recent
|
|
||||||
</div>
|
|
||||||
{visibleRecentWorkDirs.map((entry) => {
|
|
||||||
const name = basename(entry.path) || entry.path
|
|
||||||
const when = formatRecentWorkDirTime(entry.lastUsedAt)
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={entry.path}
|
|
||||||
title={entry.path}
|
|
||||||
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
|
|
||||||
className="h-8 rounded-[9px] px-2.5"
|
|
||||||
>
|
|
||||||
<FolderClock className="size-4" />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
|
||||||
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clear — only meaningful once a directory is set. Kept at the bottom. */}
|
|
||||||
{workDir && (
|
|
||||||
<>
|
|
||||||
<div className="my-1 h-px bg-border/60" />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={handleClearWorkDir}
|
|
||||||
className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
<span>Clear folder</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{workDir && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSetWorkDir}
|
|
||||||
className="flex min-w-0 items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{basename(workDir) || workDir}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearWorkDir}
|
|
||||||
aria-label="Remove work directory"
|
|
||||||
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
Work directory: {workDir}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{searchAvailable && (
|
{searchAvailable && (
|
||||||
<button
|
searchEnabled ? (
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchEnabled((v) => !v)}
|
|
||||||
aria-label="Search"
|
|
||||||
aria-pressed={searchEnabled}
|
|
||||||
className={cn(
|
|
||||||
'flex h-7 shrink-0 items-center rounded-full border px-1.5 transition-colors duration-150 ease-out',
|
|
||||||
searchEnabled
|
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900'
|
|
||||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Globe className="h-4 w-4 shrink-0" />
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out',
|
|
||||||
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setSearchEnabled(false)}
|
||||||
if (runId) return
|
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
|
||||||
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
|
|
||||||
}}
|
|
||||||
disabled={Boolean(runId)}
|
|
||||||
className={cn(
|
|
||||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
|
|
||||||
permissionMode === 'auto'
|
|
||||||
? "bg-secondary text-foreground hover:bg-secondary/70"
|
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
||||||
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
|
|
||||||
)}
|
|
||||||
aria-label="Permission mode"
|
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-3.5 w-3.5" />
|
<Globe className="h-3.5 w-3.5" />
|
||||||
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
|
<span className="text-xs font-medium">Search</span>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
) : (
|
||||||
<TooltipContent side="top">
|
<button
|
||||||
{runId
|
type="button"
|
||||||
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
|
onClick={() => setSearchEnabled(true)}
|
||||||
: permissionMode === 'auto'
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
? 'Auto-permission on — click for manual approval prompts'
|
aria-label="Search"
|
||||||
: 'Manual approval prompts — click for auto-permission'}
|
>
|
||||||
</TooltipContent>
|
<Globe className="h-4 w-4" />
|
||||||
</Tooltip>
|
</button>
|
||||||
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
)
|
||||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
)}
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCodeModeEnabled(false)}
|
|
||||||
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
|
|
||||||
>
|
|
||||||
<Terminal className="h-3.5 w-3.5" />
|
|
||||||
<span>Code</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">Code mode on — click to disable</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="text-foreground/30">·</span>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggleCodingAgent}
|
|
||||||
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
|
|
||||||
>
|
|
||||||
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCodeModeEnabled(true)}
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
||||||
aria-label="Code mode"
|
|
||||||
>
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{lockedModel ? (
|
{configuredModels.length > 0 && (
|
||||||
<span
|
|
||||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
|
||||||
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
|
||||||
>
|
|
||||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
|
||||||
</span>
|
|
||||||
) : configuredModels.length > 0 ? (
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1005,7 +563,7 @@ function ChatInputInner({
|
||||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span className="max-w-[150px] truncate">
|
<span className="max-w-[150px] truncate">
|
||||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1013,18 +571,18 @@ function ChatInputInner({
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
||||||
{configuredModels.map((m) => {
|
{configuredModels.map((m) => {
|
||||||
const key = `${m.provider}/${m.model}`
|
const key = `${m.flavor}/${m.model}`
|
||||||
return (
|
return (
|
||||||
<DropdownMenuRadioItem key={key} value={key}>
|
<DropdownMenuRadioItem key={key} value={key}>
|
||||||
<span className="truncate">{m.model}</span>
|
<span className="truncate">{m.model}</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
|
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
)}
|
||||||
{onToggleTts && ttsAvailable && (
|
{onToggleTts && ttsAvailable && (
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -1149,7 +707,7 @@ export interface ChatInputWithMentionsProps {
|
||||||
knowledgeFiles: string[]
|
knowledgeFiles: string[]
|
||||||
recentFiles: string[]
|
recentFiles: string[]
|
||||||
visibleFiles: string[]
|
visibleFiles: string[]
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
@ -1171,9 +729,6 @@ export interface ChatInputWithMentionsProps {
|
||||||
ttsMode?: 'summary' | 'full'
|
ttsMode?: 'summary' | 'full'
|
||||||
onToggleTts?: () => void
|
onToggleTts?: () => void
|
||||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
|
||||||
workDir?: string | null
|
|
||||||
onWorkDirChange?: (value: string | null) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInputWithMentions({
|
export function ChatInputWithMentions({
|
||||||
|
|
@ -1202,9 +757,6 @@ export function ChatInputWithMentions({
|
||||||
ttsMode,
|
ttsMode,
|
||||||
onToggleTts,
|
onToggleTts,
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onSelectedModelChange,
|
|
||||||
workDir,
|
|
||||||
onWorkDirChange,
|
|
||||||
}: ChatInputWithMentionsProps) {
|
}: ChatInputWithMentionsProps) {
|
||||||
return (
|
return (
|
||||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||||
|
|
@ -1231,9 +783,6 @@ export function ChatInputWithMentions({
|
||||||
ttsMode={ttsMode}
|
ttsMode={ttsMode}
|
||||||
onToggleTts={onToggleTts}
|
onToggleTts={onToggleTts}
|
||||||
onTtsModeChange={onTtsModeChange}
|
onTtsModeChange={onTtsModeChange}
|
||||||
onSelectedModelChange={onSelectedModelChange}
|
|
||||||
workDir={workDir}
|
|
||||||
onWorkDirChange={onWorkDirChange}
|
|
||||||
/>
|
/>
|
||||||
</PromptInputProvider>
|
</PromptInputProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
|
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { ChatHeader } from '@/components/chat-header'
|
|
||||||
import { ChatEmptyState } from '@/components/chat-empty-state'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
|
ConversationEmptyState,
|
||||||
ConversationScrollButton,
|
ConversationScrollButton,
|
||||||
} from '@/components/ai-elements/conversation'
|
} from '@/components/ai-elements/conversation'
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,24 +16,19 @@ import {
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from '@/components/ai-elements/message'
|
} from '@/components/ai-elements/message'
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||||
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
|
|
||||||
import { TerminalOutput } from '@/components/terminal-output'
|
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||||
|
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
import { defaultRemarkPlugins } from 'streamdown'
|
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||||
import { type ChatTab } from '@/components/tab-bar'
|
|
||||||
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
|
||||||
import { wikiLabel } from '@/lib/wiki-links'
|
import { wikiLabel } from '@/lib/wiki-links'
|
||||||
import type { ChatPaneSize } from '@/contexts/theme-context'
|
|
||||||
import {
|
import {
|
||||||
type ChatViewportAnchorState,
|
type ChatViewportAnchorState,
|
||||||
type ChatTabViewState,
|
type ChatTabViewState,
|
||||||
|
|
@ -51,47 +38,62 @@ import {
|
||||||
getWebSearchCardData,
|
getWebSearchCardData,
|
||||||
getComposioConnectCardData,
|
getComposioConnectCardData,
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
groupConversationItems,
|
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
isToolGroup,
|
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
toToolState,
|
toToolState,
|
||||||
} from '@/lib/chat-conversation'
|
} from '@/lib/chat-conversation'
|
||||||
import { matchBillingError } from '@/lib/billing-error'
|
|
||||||
|
|
||||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||||
|
|
||||||
// Render user messages with markdown so bullets, bold, links, etc. survive the
|
/* ─── Billing error helpers ─── */
|
||||||
// round-trip from the input textarea. `remarkBreaks` turns single newlines
|
|
||||||
// into <br> so typed line breaks are preserved without requiring blank lines.
|
|
||||||
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
|
||||||
|
|
||||||
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
|
const BILLING_ERROR_PATTERNS = [
|
||||||
const ref = useRef<HTMLPreElement>(null)
|
{
|
||||||
const stickToBottom = useRef(true)
|
pattern: /upgrade required/i,
|
||||||
|
title: 'A subscription is required',
|
||||||
|
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||||
|
cta: 'Subscribe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /not enough credits/i,
|
||||||
|
title: 'You\'ve run out of credits',
|
||||||
|
subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.',
|
||||||
|
cta: 'Upgrade plan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /subscription not active/i,
|
||||||
|
title: 'Your subscription is inactive',
|
||||||
|
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||||
|
cta: 'Reactivate',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function matchBillingError(message: string) {
|
||||||
|
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillingErrorCTA({ label }: { label: string }) {
|
||||||
|
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
window.ipc.invoke('account:getRowboat', null)
|
||||||
if (el && stickToBottom.current) {
|
.then((account: any) => setAppUrl(account.config?.appUrl ?? null))
|
||||||
el.scrollTop = el.scrollHeight
|
.catch(() => {})
|
||||||
}
|
|
||||||
}, [children])
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const el = ref.current
|
|
||||||
if (!el) return
|
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
|
||||||
stickToBottom.current = atBottom
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (!appUrl) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre ref={ref} onScroll={handleScroll} className={className}>
|
<button
|
||||||
{children}
|
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
|
||||||
</pre>
|
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,16 +128,13 @@ interface ChatSidebarProps {
|
||||||
defaultWidth?: number
|
defaultWidth?: number
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
isMaximized?: boolean
|
isMaximized?: boolean
|
||||||
placement?: 'middle' | 'right'
|
|
||||||
paneSize?: ChatPaneSize
|
|
||||||
className?: string
|
|
||||||
chatTabs: ChatTab[]
|
chatTabs: ChatTab[]
|
||||||
activeChatTabId: string
|
activeChatTabId: string
|
||||||
getChatTabTitle: (tab: ChatTab) => string
|
getChatTabTitle: (tab: ChatTab) => string
|
||||||
|
isChatTabProcessing: (tab: ChatTab) => boolean
|
||||||
|
onSwitchChatTab: (tabId: string) => void
|
||||||
|
onCloseChatTab: (tabId: string) => void
|
||||||
onNewChatTab: () => void
|
onNewChatTab: () => void
|
||||||
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
|
||||||
onSelectRun?: (runId: string) => void
|
|
||||||
onOpenChatHistory?: () => void
|
|
||||||
onOpenFullScreen?: () => void
|
onOpenFullScreen?: () => void
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
|
|
@ -144,7 +143,7 @@ interface ChatSidebarProps {
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
knowledgeFiles?: string[]
|
knowledgeFiles?: string[]
|
||||||
recentFiles?: string[]
|
recentFiles?: string[]
|
||||||
visibleFiles?: string[]
|
visibleFiles?: string[]
|
||||||
|
|
@ -153,20 +152,15 @@ interface ChatSidebarProps {
|
||||||
onPresetMessageConsumed?: () => void
|
onPresetMessageConsumed?: () => void
|
||||||
getInitialDraft?: (tabId: string) => string | undefined
|
getInitialDraft?: (tabId: string) => string | undefined
|
||||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
|
||||||
workDirByTab?: Record<string, string | null>
|
|
||||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
|
||||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||||
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
|
|
||||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
||||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||||
onOpenKnowledgeFile?: (path: string) => void
|
onOpenKnowledgeFile?: (path: string) => void
|
||||||
onActivate?: () => void
|
onActivate?: () => void
|
||||||
collapsedLeftPaddingPx?: number
|
|
||||||
// Voice / TTS props
|
// Voice / TTS props
|
||||||
isRecording?: boolean
|
isRecording?: boolean
|
||||||
recordingText?: string
|
recordingText?: string
|
||||||
|
|
@ -187,16 +181,13 @@ export function ChatSidebar({
|
||||||
defaultWidth = DEFAULT_WIDTH,
|
defaultWidth = DEFAULT_WIDTH,
|
||||||
isOpen = true,
|
isOpen = true,
|
||||||
isMaximized = false,
|
isMaximized = false,
|
||||||
placement = 'right',
|
|
||||||
paneSize = 'chat-smaller',
|
|
||||||
className,
|
|
||||||
chatTabs,
|
chatTabs,
|
||||||
activeChatTabId,
|
activeChatTabId,
|
||||||
getChatTabTitle,
|
getChatTabTitle,
|
||||||
|
isChatTabProcessing,
|
||||||
|
onSwitchChatTab,
|
||||||
|
onCloseChatTab,
|
||||||
onNewChatTab,
|
onNewChatTab,
|
||||||
recentRuns = [],
|
|
||||||
onSelectRun,
|
|
||||||
onOpenChatHistory,
|
|
||||||
onOpenFullScreen,
|
onOpenFullScreen,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
|
|
@ -214,20 +205,15 @@ export function ChatSidebar({
|
||||||
onPresetMessageConsumed,
|
onPresetMessageConsumed,
|
||||||
getInitialDraft,
|
getInitialDraft,
|
||||||
onDraftChangeForTab,
|
onDraftChangeForTab,
|
||||||
onSelectedModelChangeForTab,
|
|
||||||
workDirByTab = {},
|
|
||||||
onWorkDirChangeForTab,
|
|
||||||
pendingAskHumanRequests = new Map(),
|
pendingAskHumanRequests = new Map(),
|
||||||
allPermissionRequests = new Map(),
|
allPermissionRequests = new Map(),
|
||||||
permissionResponses = new Map(),
|
permissionResponses = new Map(),
|
||||||
autoPermissionDecisions = new Map(),
|
|
||||||
onPermissionResponse,
|
onPermissionResponse,
|
||||||
onAskHumanResponse,
|
onAskHumanResponse,
|
||||||
isToolOpenForTab,
|
isToolOpenForTab,
|
||||||
onToolOpenChangeForTab,
|
onToolOpenChangeForTab,
|
||||||
onOpenKnowledgeFile,
|
onOpenKnowledgeFile,
|
||||||
onActivate,
|
onActivate,
|
||||||
collapsedLeftPaddingPx = 196,
|
|
||||||
isRecording,
|
isRecording,
|
||||||
recordingText,
|
recordingText,
|
||||||
recordingState,
|
recordingState,
|
||||||
|
|
@ -242,7 +228,6 @@ export function ChatSidebar({
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onComposioConnected,
|
onComposioConnected,
|
||||||
}: ChatSidebarProps) {
|
}: ChatSidebarProps) {
|
||||||
const { state: sidebarState } = useSidebar()
|
|
||||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const [showContent, setShowContent] = useState(isOpen)
|
const [showContent, setShowContent] = useState(isOpen)
|
||||||
|
|
@ -253,8 +238,6 @@ export function ChatSidebar({
|
||||||
const startWidthRef = useRef(0)
|
const startWidthRef = useRef(0)
|
||||||
const prevIsMaximizedRef = useRef(isMaximized)
|
const prevIsMaximizedRef = useRef(isMaximized)
|
||||||
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
||||||
const isMiddlePlacement = placement === 'middle'
|
|
||||||
const isResizable = paneSize === 'chat-smaller'
|
|
||||||
|
|
||||||
const getMaxAllowedWidth = useCallback(() => {
|
const getMaxAllowedWidth = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return MAX_WIDTH
|
if (typeof window === 'undefined') return MAX_WIDTH
|
||||||
|
|
@ -315,9 +298,7 @@ export function ChatSidebar({
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
const delta = isMiddlePlacement
|
const delta = startXRef.current - event.clientX
|
||||||
? event.clientX - startXRef.current
|
|
||||||
: startXRef.current - event.clientX
|
|
||||||
const maxAllowedWidth = getMaxAllowedWidth()
|
const maxAllowedWidth = getMaxAllowedWidth()
|
||||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||||
}
|
}
|
||||||
|
|
@ -330,7 +311,7 @@ export function ChatSidebar({
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
}, [width, getMaxAllowedWidth, isMiddlePlacement])
|
}, [width, getMaxAllowedWidth])
|
||||||
|
|
||||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||||
runId: runId ?? null,
|
runId: runId ?? null,
|
||||||
|
|
@ -339,7 +320,6 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
autoPermissionDecisions,
|
|
||||||
}), [
|
}), [
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -347,38 +327,15 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
autoPermissionDecisions,
|
|
||||||
])
|
])
|
||||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||||
if (tabId === activeChatTabId) return activeTabState
|
if (tabId === activeChatTabId) return activeTabState
|
||||||
return chatTabStates[tabId] ?? emptyTabState
|
return chatTabStates[tabId] ?? emptyTabState
|
||||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||||
const activeRunId = activeTabState.runId
|
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
||||||
const handleDownloadChatLog = useCallback(async () => {
|
|
||||||
if (!activeRunId) {
|
|
||||||
toast.error('No chat log available yet')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Chat log saved')
|
|
||||||
} else if (result.error) {
|
|
||||||
toast.error(result.error)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Download chat log failed:', err)
|
|
||||||
toast.error('Failed to download chat log')
|
|
||||||
}
|
|
||||||
}, [activeRunId])
|
|
||||||
|
|
||||||
const renderConversationItem = (
|
|
||||||
item: ConversationItem,
|
|
||||||
tabId: string,
|
|
||||||
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
|
||||||
) => {
|
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
if (item.role === 'user') {
|
if (item.role === 'user') {
|
||||||
if (item.attachments && item.attachments.length > 0) {
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
|
@ -388,14 +345,7 @@ export function ChatSidebar({
|
||||||
<ChatMessageAttachments attachments={item.attachments} />
|
<ChatMessageAttachments attachments={item.attachments} />
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
{item.content && (
|
{item.content && (
|
||||||
<MessageContent>
|
<MessageContent>{item.content}</MessageContent>
|
||||||
<MessageResponse
|
|
||||||
components={streamdownComponents}
|
|
||||||
remarkPlugins={userMessageRemarkPlugins}
|
|
||||||
>
|
|
||||||
{item.content}
|
|
||||||
</MessageResponse>
|
|
||||||
</MessageContent>
|
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
)
|
)
|
||||||
|
|
@ -416,12 +366,7 @@ export function ChatSidebar({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MessageResponse
|
{message}
|
||||||
components={streamdownComponents}
|
|
||||||
remarkPlugins={userMessageRemarkPlugins}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</MessageResponse>
|
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
)
|
)
|
||||||
|
|
@ -471,25 +416,29 @@ export function ChatSidebar({
|
||||||
key={item.id}
|
key={item.id}
|
||||||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||||
autoPermissionDetail={options?.autoPermissionDetail}
|
|
||||||
>
|
>
|
||||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
{item.streamingOutput ? (
|
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||||
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
|
||||||
<TerminalOutput raw={item.streamingOutput} />
|
|
||||||
</AutoScrollPre>
|
|
||||||
) : (
|
|
||||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
|
||||||
)}
|
|
||||||
</ToolContent>
|
</ToolContent>
|
||||||
</Tool>
|
</Tool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isErrorMessage(item)) {
|
if (isErrorMessage(item)) {
|
||||||
if (matchBillingError(item.message)) {
|
const billingError = matchBillingError(item.message)
|
||||||
return null
|
if (billingError) {
|
||||||
|
return (
|
||||||
|
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||||
|
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
|
||||||
|
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
|
||||||
|
<BillingErrorCTA label={billingError.cta} />
|
||||||
|
</div>
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||||
|
|
@ -512,32 +461,25 @@ export function ChatSidebar({
|
||||||
// not add extra width to the right and overflow the app viewport.
|
// not add extra width to the right and overflow the app viewport.
|
||||||
return { width: 0, flex: '1 1 auto' }
|
return { width: 0, flex: '1 1 auto' }
|
||||||
}
|
}
|
||||||
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
|
|
||||||
return { width: 0, flex: '1 1 0' }
|
|
||||||
}
|
|
||||||
return { width, flex: '0 0 auto' }
|
return { width, flex: '0 0 auto' }
|
||||||
}, [isOpen, isMaximized, paneSize, width])
|
}, [isOpen, isMaximized, width])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={paneRef}
|
ref={paneRef}
|
||||||
data-chat-sidebar-root
|
|
||||||
onMouseDownCapture={onActivate}
|
onMouseDownCapture={onActivate}
|
||||||
onFocusCapture={onActivate}
|
onFocusCapture={onActivate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex min-w-0 flex-col overflow-hidden bg-background',
|
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||||
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
|
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
|
||||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
|
|
||||||
className
|
|
||||||
)}
|
)}
|
||||||
style={paneStyle}
|
style={paneStyle}
|
||||||
>
|
>
|
||||||
{!isMaximized && isResizable && (
|
{!isMaximized && (
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
|
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
||||||
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
|
|
||||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||||
'hover:after:bg-sidebar-border',
|
'hover:after:bg-sidebar-border',
|
||||||
isResizing && 'after:bg-primary'
|
isResizing && 'after:bg-primary'
|
||||||
|
|
@ -547,53 +489,29 @@ export function ChatSidebar({
|
||||||
|
|
||||||
{showContent && (
|
{showContent && (
|
||||||
<>
|
<>
|
||||||
<header
|
<header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||||
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar"
|
<TabBar
|
||||||
style={{
|
tabs={chatTabs}
|
||||||
paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined,
|
activeTabId={activeChatTabId}
|
||||||
paddingRight: isMaximized ? 12 : undefined,
|
getTabTitle={getChatTabTitle}
|
||||||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
getTabId={(tab) => tab.id}
|
||||||
}}
|
isProcessing={isChatTabProcessing}
|
||||||
>
|
onSwitchTab={onSwitchChatTab}
|
||||||
<ChatHeader
|
onCloseTab={onCloseChatTab}
|
||||||
activeTitle={(() => {
|
|
||||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
|
||||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
|
||||||
})()}
|
|
||||||
onNewChatTab={onNewChatTab}
|
|
||||||
recentRuns={recentRuns}
|
|
||||||
activeRunId={runId}
|
|
||||||
onSelectRun={onSelectRun}
|
|
||||||
onOpenChatHistory={onOpenChatHistory}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenu>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
size="icon"
|
||||||
variant="ghost"
|
onClick={onNewChatTab}
|
||||||
size="icon"
|
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label="Chat options"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">Chat options</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-48">
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={!activeRunId}
|
|
||||||
onSelect={() => {
|
|
||||||
void handleDownloadChatLog()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Bug className="size-4" />
|
<SquarePen className="size-5" />
|
||||||
Download chat log
|
</Button>
|
||||||
</DropdownMenuItem>
|
</TooltipTrigger>
|
||||||
</DropdownMenuContent>
|
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||||
</DropdownMenu>
|
</Tooltip>
|
||||||
{onOpenFullScreen && (
|
{onOpenFullScreen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -602,14 +520,14 @@ export function ChatSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onOpenFullScreen}
|
onClick={onOpenFullScreen}
|
||||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
|
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||||
>
|
>
|
||||||
{isMaximized
|
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
||||||
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
|
|
||||||
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
|
<TooltipContent side="bottom">
|
||||||
|
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -638,73 +556,31 @@ export function ChatSidebar({
|
||||||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
>
|
>
|
||||||
<ConversationContent className={cn(
|
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||||
'mx-auto w-full max-w-4xl px-3',
|
|
||||||
tabHasConversation ? 'pb-28' : 'pb-0',
|
|
||||||
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
|
|
||||||
)}>
|
|
||||||
{!tabHasConversation ? (
|
{!tabHasConversation ? (
|
||||||
<ChatEmptyState
|
<ConversationEmptyState className="h-auto">
|
||||||
wide={isMaximized}
|
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
||||||
recentRuns={recentRuns}
|
</ConversationEmptyState>
|
||||||
onSelectRun={onSelectRun}
|
|
||||||
onOpenChatHistory={onOpenChatHistory}
|
|
||||||
onPickPrompt={setLocalPresetMessage}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{tabState.conversation.map((item) => {
|
||||||
tabState.conversation,
|
const rendered = renderConversationItem(item, tab.id)
|
||||||
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
if (isToolCall(item) && onPermissionResponse) {
|
||||||
).map((item) => {
|
|
||||||
if (isToolGroup(item)) {
|
|
||||||
return (
|
|
||||||
<ToolGroupComponent
|
|
||||||
key={item.groupId}
|
|
||||||
group={item}
|
|
||||||
isToolOpen={(toolId) => isToolOpenForTab?.(tab.id, toolId) ?? false}
|
|
||||||
onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const autoDecision = isToolCall(item)
|
|
||||||
? tabState.autoPermissionDecisions.get(item.id)
|
|
||||||
: undefined
|
|
||||||
const rendered = renderConversationItem(
|
|
||||||
item,
|
|
||||||
tab.id,
|
|
||||||
autoDecision?.decision === 'allow'
|
|
||||||
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
if (isToolCall(item)) {
|
|
||||||
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
|
if (permRequest) {
|
||||||
const response = tabState.permissionResponses.get(item.id) || null
|
const response = tabState.permissionResponses.get(item.id) || null
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
{deniedAutoDecision && (
|
|
||||||
<AutoPermissionDecision
|
|
||||||
toolCall={deniedAutoDecision.toolCall}
|
|
||||||
permission={deniedAutoDecision.permission}
|
|
||||||
decision={deniedAutoDecision.decision}
|
|
||||||
reason={deniedAutoDecision.reason}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{permRequest && onPermissionResponse && (
|
|
||||||
<PermissionRequest
|
|
||||||
toolCall={permRequest.toolCall}
|
|
||||||
permission={permRequest.permission}
|
|
||||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
|
||||||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
|
||||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
|
||||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
|
||||||
isProcessing={isActive && isProcessing}
|
|
||||||
response={response}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{rendered}
|
{rendered}
|
||||||
|
<PermissionRequest
|
||||||
|
toolCall={permRequest.toolCall}
|
||||||
|
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||||
|
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||||
|
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||||
|
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||||
|
isProcessing={isActive && isProcessing}
|
||||||
|
response={response}
|
||||||
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -749,6 +625,9 @@ export function ChatSidebar({
|
||||||
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
||||||
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||||
<div className="mx-auto w-full max-w-4xl px-3">
|
<div className="mx-auto w-full max-w-4xl px-3">
|
||||||
|
{!hasConversation && (
|
||||||
|
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
||||||
|
)}
|
||||||
{chatTabs.map((tab) => {
|
{chatTabs.map((tab) => {
|
||||||
const isActive = tab.id === activeChatTabId
|
const isActive = tab.id === activeChatTabId
|
||||||
const tabState = getTabState(tab.id)
|
const tabState = getTabState(tab.id)
|
||||||
|
|
@ -776,9 +655,6 @@ export function ChatSidebar({
|
||||||
runId={tabState.runId}
|
runId={tabState.runId}
|
||||||
initialDraft={getInitialDraft?.(tab.id)}
|
initialDraft={getInitialDraft?.(tab.id)}
|
||||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
|
||||||
workDir={workDirByTab[tab.id] ?? null}
|
|
||||||
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
|
|
||||||
isRecording={isActive && isRecording}
|
isRecording={isActive && isRecording}
|
||||||
recordingText={isActive ? recordingText : undefined}
|
recordingText={isActive ? recordingText : undefined}
|
||||||
recordingState={isActive ? recordingState : undefined}
|
recordingState={isActive ? recordingState : undefined}
|
||||||
|
|
|
||||||
|
|
@ -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
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => c.handleReconnect(provider)}
|
onClick={() => {
|
||||||
|
if (provider === 'google') {
|
||||||
|
c.setGoogleClientIdDescription(
|
||||||
|
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||||
|
)
|
||||||
|
c.setGoogleClientIdOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.startConnect(provider)
|
||||||
|
}}
|
||||||
className="h-7 px-2 text-xs"
|
className="h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
Reconnect
|
Reconnect
|
||||||
|
|
|
||||||
|
|
@ -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,
|
FileTextIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
FileTypeIcon,
|
FileTypeIcon,
|
||||||
Radio,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -43,21 +42,6 @@ interface EditorToolbarProps {
|
||||||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||||
onImageUpload?: (file: File) => Promise<void> | void
|
onImageUpload?: (file: File) => Promise<void> | void
|
||||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||||
onOpenLiveNote?: () => void
|
|
||||||
liveState?: LivePillState
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error'
|
|
||||||
export interface LivePillState {
|
|
||||||
variant: LivePillVariant
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIVE_PILL_VARIANT_CLASS: Record<LivePillVariant, string> = {
|
|
||||||
passive: 'text-muted-foreground hover:bg-accent',
|
|
||||||
idle: 'text-foreground hover:bg-accent',
|
|
||||||
running: 'text-foreground bg-primary/10 hover:bg-primary/15 animate-pulse',
|
|
||||||
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorToolbar({
|
export function EditorToolbar({
|
||||||
|
|
@ -65,8 +49,6 @@ export function EditorToolbar({
|
||||||
onSelectionHighlight,
|
onSelectionHighlight,
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onExport,
|
onExport,
|
||||||
onOpenLiveNote,
|
|
||||||
liveState,
|
|
||||||
}: EditorToolbarProps) {
|
}: EditorToolbarProps) {
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||||
|
|
@ -403,19 +385,6 @@ export function EditorToolbar({
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live Note pill — pushed to far right */}
|
|
||||||
{onOpenLiveNote && liveState && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenLiveNote}
|
|
||||||
title={liveState.variant === 'passive' ? 'Make this note live' : 'Live note'}
|
|
||||||
className={`ml-auto inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors ${LIVE_PILL_VARIANT_CLASS[liveState.variant]}`}
|
|
||||||
>
|
|
||||||
<Radio className="size-3.5" />
|
|
||||||
<span className="truncate max-w-[160px]">{liveState.label}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
||||||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null {
|
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||||
const record: Record<string, string | string[]> = {}
|
const record: Record<string, string | string[]> = {}
|
||||||
for (const { key, value } of fields) {
|
for (const { key, value } of fields) {
|
||||||
if (key.trim()) record[key.trim()] = value
|
if (key.trim()) record[key.trim()] = value
|
||||||
}
|
}
|
||||||
return buildFrontmatter(record, preserveRaw)
|
return buildFrontmatter(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||||
|
|
@ -45,12 +45,10 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
|
||||||
}, [editingNewKey])
|
}, [editingNewKey])
|
||||||
|
|
||||||
const commit = useCallback((updated: FieldEntry[]) => {
|
const commit = useCallback((updated: FieldEntry[]) => {
|
||||||
// Use the latest raw seen as the preserve-source so structured keys
|
const newRaw = fieldsToRaw(updated)
|
||||||
// (like `live:`) survive a round-trip through this UI.
|
|
||||||
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
|
|
||||||
lastCommittedRaw.current = newRaw
|
lastCommittedRaw.current = newRaw
|
||||||
onRawChange(newRaw)
|
onRawChange(newRaw)
|
||||||
}, [onRawChange, raw])
|
}, [onRawChange])
|
||||||
|
|
||||||
// For scalar fields: update local state immediately, commit on blur
|
// For scalar fields: update local state immediately, commit on blur
|
||||||
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||||
|
|
|
||||||
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 { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
|
||||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
|
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
|
|
@ -7,22 +7,17 @@ import Image from '@tiptap/extension-image'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import TaskList from '@tiptap/extension-task-list'
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
import TaskItem from '@tiptap/extension-task-item'
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
|
|
||||||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
|
|
||||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||||
import { PromptBlockExtension } from '@/extensions/prompt-block'
|
|
||||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||||
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
|
||||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||||
import { TableBlockExtension } from '@/extensions/table-block'
|
import { TableBlockExtension } from '@/extensions/table-block'
|
||||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||||
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block'
|
import { EmailBlockExtension } from '@/extensions/email-block'
|
||||||
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
|
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
|
||||||
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
|
|
||||||
import { Markdown } from 'tiptap-markdown'
|
import { Markdown } from 'tiptap-markdown'
|
||||||
import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
|
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
// Zero-width space used as invisible marker for blank lines
|
// Zero-width space used as invisible marker for blank lines
|
||||||
|
|
@ -58,248 +53,164 @@ function postprocessMarkdown(markdown: string): string {
|
||||||
}).join('\n')
|
}).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
type JsonNode = {
|
// Custom function to get markdown that preserves empty paragraphs as blank lines
|
||||||
type?: string
|
function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
content?: JsonNode[]
|
const json = editor.getJSON()
|
||||||
text?: string
|
if (!json.content) return ''
|
||||||
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
|
|
||||||
attrs?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text
|
const blocks: string[] = []
|
||||||
function nodeToText(node: JsonNode): string {
|
|
||||||
if (!node.content) return ''
|
// Helper to convert a node to markdown text
|
||||||
return node.content.map(child => {
|
const nodeToText = (node: {
|
||||||
if (child.type === 'text') {
|
type?: string
|
||||||
let text = child.text || ''
|
content?: Array<{
|
||||||
if (child.marks) {
|
type?: string
|
||||||
for (const mark of child.marks) {
|
text?: string
|
||||||
if (mark.type === 'bold') text = `**${text}**`
|
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
|
||||||
else if (mark.type === 'italic') text = `*${text}*`
|
attrs?: Record<string, unknown>
|
||||||
else if (mark.type === 'code') text = `\`${text}\``
|
}>
|
||||||
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
|
attrs?: Record<string, unknown>
|
||||||
|
}): string => {
|
||||||
|
if (!node.content) return ''
|
||||||
|
return node.content.map(child => {
|
||||||
|
if (child.type === 'text') {
|
||||||
|
let text = child.text || ''
|
||||||
|
// Apply marks (bold, italic, etc.)
|
||||||
|
if (child.marks) {
|
||||||
|
for (const mark of child.marks) {
|
||||||
|
if (mark.type === 'bold') text = `**${text}**`
|
||||||
|
else if (mark.type === 'italic') text = `*${text}*`
|
||||||
|
else if (mark.type === 'code') text = `\`${text}\``
|
||||||
|
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return text
|
||||||
|
} else if (child.type === 'wikiLink') {
|
||||||
|
const path = (child.attrs?.path as string) || ''
|
||||||
|
return path ? `[[${path}]]` : ''
|
||||||
|
} else if (child.type === 'hardBreak') {
|
||||||
|
return '\n'
|
||||||
}
|
}
|
||||||
return text
|
return ''
|
||||||
} else if (child.type === 'wikiLink') {
|
}).join('')
|
||||||
const path = (child.attrs?.path as string) || ''
|
}
|
||||||
const label = (child.attrs?.label as string | null | undefined) || ''
|
|
||||||
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
|
|
||||||
} else if (child.type === 'hardBreak') {
|
|
||||||
return '\n'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively serialize a list node (one line per item; nested lists indented two spaces)
|
for (const node of json.content) {
|
||||||
function serializeList(listNode: JsonNode, indent: number): string[] {
|
if (node.type === 'paragraph') {
|
||||||
const lines: string[] = []
|
|
||||||
const items = (listNode.content || []) as JsonNode[]
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
const indentStr = ' '.repeat(indent)
|
|
||||||
let prefix: string
|
|
||||||
if (listNode.type === 'taskList') {
|
|
||||||
const checked = item.attrs?.checked ? 'x' : ' '
|
|
||||||
prefix = `- [${checked}] `
|
|
||||||
} else if (listNode.type === 'orderedList') {
|
|
||||||
prefix = `${index + 1}. `
|
|
||||||
} else {
|
|
||||||
prefix = '- '
|
|
||||||
}
|
|
||||||
const itemContent = (item.content || []) as JsonNode[]
|
|
||||||
let firstPara = true
|
|
||||||
itemContent.forEach(child => {
|
|
||||||
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
|
|
||||||
lines.push(...serializeList(child, indent + 1))
|
|
||||||
} else {
|
|
||||||
const text = nodeToText(child)
|
|
||||||
if (firstPara) {
|
|
||||||
lines.push(indentStr + prefix + text)
|
|
||||||
firstPara = false
|
|
||||||
} else {
|
|
||||||
lines.push(indentStr + ' ' + text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is
|
|
||||||
// actually invoked — the other helpers are stubs to satisfy the type.
|
|
||||||
const tableRenderHelpers: MarkdownRendererHelpers = {
|
|
||||||
renderChildren: (nodes) => {
|
|
||||||
const arr = Array.isArray(nodes) ? nodes : [nodes]
|
|
||||||
return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('')
|
|
||||||
},
|
|
||||||
wrapInBlock: (prefix, content) => prefix + content,
|
|
||||||
indent: (content) => content,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker
|
|
||||||
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
|
|
||||||
function blockToMarkdown(node: JsonNode): string {
|
|
||||||
switch (node.type) {
|
|
||||||
case 'paragraph': {
|
|
||||||
const text = nodeToText(node)
|
const text = nodeToText(node)
|
||||||
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return ''
|
// If the paragraph contains only the blank line marker or is empty, it's a blank line
|
||||||
return text
|
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) {
|
||||||
}
|
// Push empty string to represent blank line - will add extra newline when joining
|
||||||
case 'heading': {
|
blocks.push('')
|
||||||
|
} else {
|
||||||
|
blocks.push(text)
|
||||||
|
}
|
||||||
|
} else if (node.type === 'heading') {
|
||||||
const level = (node.attrs?.level as number) || 1
|
const level = (node.attrs?.level as number) || 1
|
||||||
return '#'.repeat(level) + ' ' + nodeToText(node)
|
const text = nodeToText(node)
|
||||||
}
|
blocks.push('#'.repeat(level) + ' ' + text)
|
||||||
case 'bulletList':
|
} else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
|
||||||
case 'orderedList':
|
// Recursively serialize lists to handle nested bullets
|
||||||
case 'taskList':
|
const serializeList = (
|
||||||
return serializeList(node, 0).join('\n')
|
listNode: { type?: string; content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> },
|
||||||
case 'taskBlock':
|
indent: number
|
||||||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
): string[] => {
|
||||||
case 'promptBlock':
|
const lines: string[] = []
|
||||||
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
|
const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
|
||||||
case 'imageBlock':
|
items.forEach((item, index) => {
|
||||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
const indentStr = ' '.repeat(indent)
|
||||||
case 'embedBlock':
|
let prefix: string
|
||||||
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
if (listNode.type === 'taskList') {
|
||||||
case 'iframeBlock':
|
const checked = item.attrs?.checked ? 'x' : ' '
|
||||||
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
|
prefix = `- [${checked}] `
|
||||||
case 'chartBlock':
|
} else if (listNode.type === 'orderedList') {
|
||||||
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
prefix = `${index + 1}. `
|
||||||
case 'tableBlock':
|
} else {
|
||||||
return '```table\n' + (node.attrs?.data as string || '{}') + '\n```'
|
prefix = '- '
|
||||||
case 'calendarBlock':
|
}
|
||||||
return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```'
|
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||||
case 'emailBlock':
|
let firstPara = true
|
||||||
return '```email\n' + (node.attrs?.data as string || '{}') + '\n```'
|
itemContent.forEach(child => {
|
||||||
case 'transcriptBlock':
|
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
|
||||||
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
|
lines.push(...serializeList(child, indent + 1))
|
||||||
case 'mermaidBlock':
|
} else {
|
||||||
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
|
const text = nodeToText(child)
|
||||||
case 'table':
|
if (firstPara) {
|
||||||
return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim()
|
lines.push(indentStr + prefix + text)
|
||||||
case 'codeBlock': {
|
firstPara = false
|
||||||
|
} else {
|
||||||
|
lines.push(indentStr + ' ' + text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
blocks.push(serializeList(node, 0).join('\n'))
|
||||||
|
} else if (node.type === 'taskBlock') {
|
||||||
|
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'imageBlock') {
|
||||||
|
blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'embedBlock') {
|
||||||
|
blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'chartBlock') {
|
||||||
|
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'tableBlock') {
|
||||||
|
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'calendarBlock') {
|
||||||
|
blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'emailBlock') {
|
||||||
|
blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'transcriptBlock') {
|
||||||
|
blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'codeBlock') {
|
||||||
const lang = (node.attrs?.language as string) || ''
|
const lang = (node.attrs?.language as string) || ''
|
||||||
return '```' + lang + '\n' + nodeToText(node) + '\n```'
|
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||||
}
|
} else if (node.type === 'blockquote') {
|
||||||
case 'blockquote': {
|
const content = node.content || []
|
||||||
const content = (node.content || []) as JsonNode[]
|
const quoteLines = content.map(para => '> ' + nodeToText(para))
|
||||||
return content.map(para => '> ' + nodeToText(para)).join('\n')
|
blocks.push(quoteLines.join('\n'))
|
||||||
}
|
} else if (node.type === 'horizontalRule') {
|
||||||
case 'horizontalRule':
|
blocks.push('---')
|
||||||
return '---'
|
} else if (node.type === 'wikiLink') {
|
||||||
case 'wikiLink': {
|
|
||||||
const path = (node.attrs?.path as string) || ''
|
const path = (node.attrs?.path as string) || ''
|
||||||
const label = (node.attrs?.label as string | null | undefined) || ''
|
blocks.push(`[[${path}]]`)
|
||||||
return `[[${path}${label ? `|${label}` : ''}]]`
|
} else if (node.type === 'image') {
|
||||||
}
|
|
||||||
case 'image': {
|
|
||||||
const src = (node.attrs?.src as string) || ''
|
const src = (node.attrs?.src as string) || ''
|
||||||
const alt = (node.attrs?.alt as string) || ''
|
const alt = (node.attrs?.alt as string) || ''
|
||||||
return ``
|
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
|
||||||
// Custom join: content blocks get \n\n before them, empty blocks add \n each.
|
// This produces: 1 empty paragraph = 3 newlines (1 blank line on disk)
|
||||||
// 1 empty paragraph = 3 newlines on disk (1 blank line).
|
|
||||||
function serializeBlocksToMarkdown(blocks: JsonNode[]): string {
|
|
||||||
if (blocks.length === 0) return ''
|
if (blocks.length === 0) return ''
|
||||||
|
|
||||||
let result = ''
|
let result = ''
|
||||||
|
|
||||||
for (let i = 0; i < blocks.length; i++) {
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
const block = blockToMarkdown(blocks[i])
|
const block = blocks[i]
|
||||||
const isContent = block !== ''
|
const isContent = block !== ''
|
||||||
|
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
result = block
|
result = block
|
||||||
} else if (isContent) {
|
} else if (isContent) {
|
||||||
|
// Content block: add \n\n before it (standard paragraph break)
|
||||||
result += '\n\n' + block
|
result += '\n\n' + block
|
||||||
} else {
|
} else {
|
||||||
|
// Empty block: just add \n (one extra newline for blank line)
|
||||||
result += '\n'
|
result += '\n'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
import { EditorToolbar } from './editor-toolbar'
|
||||||
// Custom function to get markdown that preserves empty paragraphs as blank lines
|
|
||||||
function getMarkdownWithBlankLines(editor: Editor): string {
|
|
||||||
const json = editor.getJSON() as JsonNode
|
|
||||||
if (!json.content) return ''
|
|
||||||
return serializeBlocksToMarkdown(json.content as JsonNode[])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines
|
|
||||||
// would produce. Used to attach precise line-references when inserting editor-context mentions.
|
|
||||||
function getCursorContextLine(editor: Editor): number {
|
|
||||||
const $from = editor.state.selection.$from
|
|
||||||
const json = editor.getJSON() as JsonNode
|
|
||||||
const blocks = (json.content ?? []) as JsonNode[]
|
|
||||||
if (blocks.length === 0) return 1
|
|
||||||
|
|
||||||
const blockIndex = $from.index(0)
|
|
||||||
if (blockIndex < 0 || blockIndex >= blocks.length) return 1
|
|
||||||
|
|
||||||
// Line where the cursor's top-level block starts.
|
|
||||||
// Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line.
|
|
||||||
let blockStartLine: number
|
|
||||||
if (blockIndex === 0) {
|
|
||||||
blockStartLine = 1
|
|
||||||
} else {
|
|
||||||
const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex))
|
|
||||||
const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length
|
|
||||||
blockStartLine = prefixLineCount + 2
|
|
||||||
}
|
|
||||||
|
|
||||||
return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading);
|
|
||||||
// for multi-line containers, computed against how the block serializes.
|
|
||||||
function computeWithinBlockOffset(
|
|
||||||
block: JsonNode,
|
|
||||||
$from: { parentOffset: number; depth: number; index: (depth: number) => number }
|
|
||||||
): number {
|
|
||||||
switch (block.type) {
|
|
||||||
case 'paragraph':
|
|
||||||
case 'heading': {
|
|
||||||
// Each hardBreak before the cursor moves us down one rendered line.
|
|
||||||
const offset = $from.parentOffset
|
|
||||||
let pos = 0
|
|
||||||
let hbCount = 0
|
|
||||||
for (const child of (block.content ?? [])) {
|
|
||||||
if (pos >= offset) break
|
|
||||||
const size = child.type === 'text' ? (child.text?.length ?? 0) : 1
|
|
||||||
if (child.type === 'hardBreak' && pos < offset) hbCount++
|
|
||||||
pos += size
|
|
||||||
}
|
|
||||||
return hbCount
|
|
||||||
}
|
|
||||||
case 'bulletList':
|
|
||||||
case 'orderedList':
|
|
||||||
case 'taskList':
|
|
||||||
case 'blockquote':
|
|
||||||
// Item index within the container = lines into the block (one item per line for shallow lists/quotes).
|
|
||||||
return $from.depth >= 1 ? $from.index(1) : 0
|
|
||||||
case 'codeBlock': {
|
|
||||||
// +1 for the opening ``` fence line, plus newlines within the code text before the cursor.
|
|
||||||
const text = block.content?.[0]?.text ?? ''
|
|
||||||
const before = text.substring(0, $from.parentOffset)
|
|
||||||
return 1 + (before.match(/\n/g)?.length ?? 0)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
import { EditorToolbar, type LivePillState } from './editor-toolbar'
|
|
||||||
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
|
|
||||||
import { formatRelativeTime } from '@/lib/relative-time'
|
|
||||||
import { FrontmatterProperties } from './frontmatter-properties'
|
import { FrontmatterProperties } from './frontmatter-properties'
|
||||||
import { WikiLink } from '@/extensions/wiki-link'
|
import { WikiLink } from '@/extensions/wiki-link'
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
|
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||||
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||||
import { RowboatMentionPopover } from './rowboat-mention-popover'
|
import { RowboatMentionPopover } from './rowboat-mention-popover'
|
||||||
import '@/styles/editor.css'
|
import '@/styles/editor.css'
|
||||||
|
|
@ -525,112 +436,7 @@ const TabIndentExtension = Extension.create({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const slugifyHeading = (text: string) =>
|
export function MarkdownEditor({
|
||||||
text
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
|
|
||||||
const decodeLinkTarget = (target: string) => {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(target)
|
|
||||||
} catch {
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToHeading = (view: EditorView, rawTarget: string) => {
|
|
||||||
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
|
|
||||||
if (!target) return false
|
|
||||||
|
|
||||||
const targetSlug = slugifyHeading(target)
|
|
||||||
let foundPos: number | null = null
|
|
||||||
view.state.doc.descendants((node, pos) => {
|
|
||||||
if (node.type.name !== 'heading') return true
|
|
||||||
const headingText = node.textContent.trim()
|
|
||||||
if (
|
|
||||||
headingText.toLowerCase() === target.toLowerCase()
|
|
||||||
|| slugifyHeading(headingText) === targetSlug
|
|
||||||
) {
|
|
||||||
foundPos = pos
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (foundPos === null) return false
|
|
||||||
|
|
||||||
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
|
|
||||||
view.dispatch(
|
|
||||||
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
|
|
||||||
)
|
|
||||||
view.focus()
|
|
||||||
|
|
||||||
const domAtPos = view.domAtPos(foundPos + 1)
|
|
||||||
const node = domAtPos.node
|
|
||||||
const headingEl = node.nodeType === Node.ELEMENT_NODE
|
|
||||||
? (node as HTMLElement)
|
|
||||||
: node.parentElement
|
|
||||||
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripMarkdownExtension = (path: string) =>
|
|
||||||
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
|
|
||||||
|
|
||||||
const isSameNotePath = (linkPath: string, notePath?: string) => {
|
|
||||||
if (!notePath) return false
|
|
||||||
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
|
|
||||||
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
|
|
||||||
return normalizedLink === normalizedNote
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExternalHref = (href: string) =>
|
|
||||||
/^(https?:|mailto:|tel:)/i.test(href)
|
|
||||||
|
|
||||||
const collapseRelativeSegments = (relPath: string) => {
|
|
||||||
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
|
|
||||||
const stack: string[] = []
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part === '..') {
|
|
||||||
if (stack.length === 0) return null
|
|
||||||
stack.pop()
|
|
||||||
} else {
|
|
||||||
stack.push(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stack.join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
|
|
||||||
const withoutHash = href.split('#')[0]
|
|
||||||
const withoutQuery = withoutHash.split('?')[0]
|
|
||||||
const decoded = decodeLinkTarget(withoutQuery)
|
|
||||||
if (!decoded) return null
|
|
||||||
|
|
||||||
if (/^file:\/\//i.test(decoded)) {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(new URL(decoded).pathname)
|
|
||||||
} catch {
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
|
|
||||||
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
|
|
||||||
|
|
||||||
const noteDir = notePath.split('/').slice(0, -1).join('/')
|
|
||||||
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarkdownEditorHandle {
|
|
||||||
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
|
|
||||||
getCursorContext: () => { path: string; lineNumber: number } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
|
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
onPrimaryHeadingCommit,
|
onPrimaryHeadingCommit,
|
||||||
|
|
@ -645,16 +451,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
onFrontmatterChange,
|
onFrontmatterChange,
|
||||||
onExport,
|
onExport,
|
||||||
notePath,
|
notePath,
|
||||||
}, ref) {
|
}: MarkdownEditorProps) {
|
||||||
const isInternalUpdate = useRef(false)
|
const isInternalUpdate = useRef(false)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
// Read wikiLinks lazily inside the editor config via this ref. wikiLinks changes
|
|
||||||
// identity whenever the workspace directory tree changes (file watcher → new file
|
|
||||||
// list), and it used to be a useEditor() dependency — so any background write to
|
|
||||||
// the workspace destroyed and recreated the entire editor, resetting scroll to the
|
|
||||||
// top. Keeping it off the dep array (and reading the ref at event time) means the
|
|
||||||
// editor instance survives directory changes.
|
|
||||||
const wikiLinksRef = useRef(wikiLinks)
|
|
||||||
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
||||||
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
||||||
|
|
@ -677,7 +476,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
|
|
||||||
// Keep ref in sync with state for the plugin to access
|
// Keep ref in sync with state for the plugin to access
|
||||||
selectionHighlightRef.current = selectionHighlight
|
selectionHighlightRef.current = selectionHighlight
|
||||||
wikiLinksRef.current = wikiLinks
|
|
||||||
|
|
||||||
// Memoize the selection highlight extension
|
// Memoize the selection highlight extension
|
||||||
const selectionHighlightExtension = useMemo(
|
const selectionHighlightExtension = useMemo(
|
||||||
|
|
@ -754,7 +552,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
heading: {
|
heading: {
|
||||||
levels: [1, 2, 3],
|
levels: [1, 2, 3],
|
||||||
},
|
},
|
||||||
link: false,
|
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
|
|
@ -772,29 +569,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
}),
|
}),
|
||||||
ImageUploadPlaceholderExtension,
|
ImageUploadPlaceholderExtension,
|
||||||
TaskBlockExtension,
|
TaskBlockExtension,
|
||||||
PromptBlockExtension.configure({ notePath }),
|
|
||||||
ImageBlockExtension,
|
ImageBlockExtension,
|
||||||
EmbedBlockExtension,
|
EmbedBlockExtension,
|
||||||
IframeBlockExtension,
|
|
||||||
ChartBlockExtension,
|
ChartBlockExtension,
|
||||||
TableBlockExtension,
|
TableBlockExtension,
|
||||||
CalendarBlockExtension,
|
CalendarBlockExtension,
|
||||||
EmailsBlockExtension,
|
|
||||||
EmailBlockExtension,
|
EmailBlockExtension,
|
||||||
TranscriptBlockExtension,
|
TranscriptBlockExtension,
|
||||||
MermaidBlockExtension,
|
|
||||||
WikiLink.configure({
|
WikiLink.configure({
|
||||||
onCreate: (path: string) => {
|
onCreate: wikiLinks?.onCreate
|
||||||
void wikiLinksRef.current?.onCreate?.(path)
|
? (path) => {
|
||||||
},
|
void wikiLinks.onCreate(path)
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
TableKit.configure({
|
|
||||||
table: { resizable: false },
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
|
@ -913,57 +705,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||||
if (node.type.name === 'wikiLink') {
|
if (node.type.name === 'wikiLink') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const wikiPath = String(node.attrs.path ?? '')
|
wikiLinks?.onOpen?.(node.attrs.path)
|
||||||
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
|
|
||||||
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
|
|
||||||
return scrollToHeading(_view, heading)
|
|
||||||
}
|
|
||||||
wikiLinksRef.current?.onOpen?.(node.attrs.path)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
handleDOMEvents: {
|
|
||||||
click: (view, event) => {
|
|
||||||
const target = event.target as Element | null
|
|
||||||
const link = target?.closest('a[href]') as HTMLAnchorElement | null
|
|
||||||
if (!link) return false
|
|
||||||
if (link.dataset.type === 'wiki-link') return false
|
|
||||||
|
|
||||||
const href = link.getAttribute('href') ?? ''
|
|
||||||
if (!href) return false
|
|
||||||
|
|
||||||
if (href.startsWith('#')) {
|
|
||||||
event.preventDefault()
|
|
||||||
return scrollToHeading(view, href)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isExternalHref(href)) {
|
|
||||||
event.preventDefault()
|
|
||||||
window.open(href, '_blank')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
|
|
||||||
if (!workspacePath) return false
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
|
|
||||||
if (result.error) console.error('Failed to open linked file:', result.error)
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('Failed to open linked file:', err)
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// NOTE: wikiLinks is intentionally NOT a dependency — it's read via wikiLinksRef
|
|
||||||
// at event time. Including it rebuilds the whole editor on every directory change
|
|
||||||
// (file watcher), which resets scroll to the top. See wikiLinksRef declaration.
|
|
||||||
}, [
|
}, [
|
||||||
editorSessionKey,
|
editorSessionKey,
|
||||||
maybeCommitPrimaryHeading,
|
maybeCommitPrimaryHeading,
|
||||||
notePath,
|
|
||||||
preventTitleHeadingDemotion,
|
preventTitleHeadingDemotion,
|
||||||
promoteFirstParagraphToTitleHeading,
|
promoteFirstParagraphToTitleHeading,
|
||||||
])
|
])
|
||||||
|
|
@ -1035,17 +785,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
})
|
})
|
||||||
}, [editor, wikiLinks])
|
}, [editor, wikiLinks])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
getCursorContext: () => {
|
|
||||||
if (!notePath || !editor) return null
|
|
||||||
try {
|
|
||||||
return { path: notePath, lineNumber: getCursorContextLine(editor) }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}), [notePath, editor])
|
|
||||||
|
|
||||||
const updateRowboatMentionState = useCallback(() => {
|
const updateRowboatMentionState = useCallback(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const { selection } = editor.state
|
const { selection } = editor.state
|
||||||
|
|
@ -1211,37 +950,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
// Normalize for comparison (trim trailing whitespace from lines)
|
// Normalize for comparison (trim trailing whitespace from lines)
|
||||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||||
// Preserve scroll + selection across an external content sync. setContent()
|
|
||||||
// resets the selection to the top of the doc and ProseMirror scrolls it into
|
|
||||||
// view; without restoring, a background writer touching the open file (graph
|
|
||||||
// builder, live-note runner, version-history commit) yanks the viewport back
|
|
||||||
// to the top repeatedly — making the note impossible to scroll. This editor
|
|
||||||
// instance is bound to a single note path, so the prior scrollTop is always
|
|
||||||
// valid for the reloaded content.
|
|
||||||
const wrapper = wrapperRef.current
|
|
||||||
const prevScrollTop = wrapper?.scrollTop ?? 0
|
|
||||||
const hadFocus = editor.isFocused
|
|
||||||
const { from: prevFrom, to: prevTo } = editor.state.selection
|
|
||||||
|
|
||||||
isInternalUpdate.current = true
|
isInternalUpdate.current = true
|
||||||
|
// Pre-process to preserve blank lines
|
||||||
const preprocessed = preprocessMarkdown(content)
|
const preprocessed = preprocessMarkdown(content)
|
||||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||||
|
|
||||||
// Only restore the caret for a focused editor, so we never steal focus or
|
|
||||||
// scroll for a passive viewer. Clamp to the (possibly shorter) new doc.
|
|
||||||
if (hadFocus) {
|
|
||||||
const docSize = editor.state.doc.content.size
|
|
||||||
const from = Math.min(prevFrom, docSize)
|
|
||||||
const to = Math.min(prevTo, docSize)
|
|
||||||
try {
|
|
||||||
editor.chain().setMeta('addToHistory', false).setTextSelection({ from, to }).run()
|
|
||||||
} catch { /* selection no longer valid in the new doc — ignore */ }
|
|
||||||
}
|
|
||||||
isInternalUpdate.current = false
|
isInternalUpdate.current = false
|
||||||
|
|
||||||
// Restore scroll last so it wins over any scrollIntoView triggered above.
|
|
||||||
if (wrapper) wrapper.scrollTop = prevScrollTop
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, content])
|
}, [editor, content])
|
||||||
|
|
@ -1601,26 +1315,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
return createImageUploadHandler(editor, onImageUpload)
|
return createImageUploadHandler(editor, onImageUpload)
|
||||||
}, [editor, onImageUpload])
|
}, [editor, onImageUpload])
|
||||||
|
|
||||||
// Live-note pill state for the toolbar — derived from the on-disk `live:`
|
|
||||||
// block plus the agent-status bus. The `tick` dependency keeps the relative
|
|
||||||
// time label fresh as minutes roll over.
|
|
||||||
const { live: currentLive, isRunning: liveIsRunning, tick: liveTick } = useLiveNoteForPath(notePath)
|
|
||||||
const livePillStateForCurrentNote: LivePillState = useMemo(() => {
|
|
||||||
void liveTick // re-run on tick to refresh relative-time label
|
|
||||||
if (!currentLive) return { variant: 'passive', label: 'Make live' }
|
|
||||||
if (liveIsRunning) return { variant: 'running', label: 'Updating…' }
|
|
||||||
if (currentLive.lastRunError) {
|
|
||||||
const when = currentLive.lastAttemptAt ? formatRelativeTime(currentLive.lastAttemptAt) : ''
|
|
||||||
return { variant: 'error', label: when ? `Live · failed ${when}` : 'Live · failed' }
|
|
||||||
}
|
|
||||||
if (currentLive.active === false) return { variant: 'passive', label: 'Live · paused' }
|
|
||||||
if (currentLive.lastRunAt) {
|
|
||||||
const when = formatRelativeTime(currentLive.lastRunAt)
|
|
||||||
return { variant: 'idle', label: when ? `Live · ${when}` : 'Live' }
|
|
||||||
}
|
|
||||||
return { variant: 'idle', label: 'Live · never run' }
|
|
||||||
}, [currentLive, liveIsRunning, liveTick])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
|
|
@ -1628,12 +1322,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
onSelectionHighlight={setSelectionHighlight}
|
onSelectionHighlight={setSelectionHighlight}
|
||||||
onImageUpload={handleImageUploadWithPlaceholder}
|
onImageUpload={handleImageUploadWithPlaceholder}
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
onOpenLiveNote={notePath ? () => {
|
|
||||||
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
|
|
||||||
detail: { filePath: notePath },
|
|
||||||
}))
|
|
||||||
} : undefined}
|
|
||||||
liveState={notePath ? livePillStateForCurrentNote : undefined}
|
|
||||||
/>
|
/>
|
||||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||||
<FrontmatterProperties
|
<FrontmatterProperties
|
||||||
|
|
@ -1760,4 +1448,4 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
|
|
@ -96,20 +96,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
// Composio/Gmail state
|
||||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||||
const [useComposioForGoogle] = useState(false)
|
|
||||||
const [gmailConnected, setGmailConnected] = useState(false)
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
const [gmailLoading, setGmailLoading] = useState(true)
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
|
|
||||||
const [useComposioForGoogleCalendar] = useState(false)
|
// Composio/Google Calendar state
|
||||||
|
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||||
|
|
||||||
const updateProviderConfig = useCallback(
|
const updateProviderConfig = useCallback(
|
||||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[provider]: { ...prev[provider], ...updates },
|
[provider]: { ...prev[provider], ...updates },
|
||||||
|
|
@ -151,8 +151,25 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
setProvidersLoading(false)
|
setProvidersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// (Composio Gmail/Calendar flag fetches removed — sync was deleted.)
|
async function loadComposioForGoogleFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||||
|
setUseComposioForGoogle(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadComposioForGoogleCalendarFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||||
|
setUseComposioForGoogleCalendar(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
loadProviders()
|
loadProviders()
|
||||||
|
loadComposioForGoogleFlag()
|
||||||
|
loadComposioForGoogleCalendarFlag()
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Load LLM models catalog on open
|
// Load LLM models catalog on open
|
||||||
|
|
@ -441,8 +458,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||||
const model = activeConfig.model.trim()
|
const model = activeConfig.model.trim()
|
||||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
|
||||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
flavor: llmProvider,
|
flavor: llmProvider,
|
||||||
|
|
@ -451,8 +466,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
knowledgeGraphModel,
|
knowledgeGraphModel,
|
||||||
meetingNotesModel,
|
|
||||||
liveNoteAgentModel,
|
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -605,20 +618,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
// Connect to a provider
|
// Connect to a provider
|
||||||
const handleConnect = useCallback(async (provider: string) => {
|
const handleConnect = useCallback(async (provider: string) => {
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
|
||||||
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
|
|
||||||
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
|
|
||||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
|
||||||
if (isSignedIntoRowboat) {
|
|
||||||
await startConnect('google')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGoogleClientIdOpen(true)
|
setGoogleClientIdOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await startConnect(provider)
|
await startConnect(provider)
|
||||||
}, [startConnect, providerStates])
|
}, [startConnect])
|
||||||
|
|
||||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
|
|
@ -1152,72 +1157,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.meetingNotesModel}
|
|
||||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
|
||||||
placeholder={activeConfig.model || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.meetingNotesModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((model) => (
|
|
||||||
<SelectItem key={model.id} value={model.id}>
|
|
||||||
{model.name || model.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.liveNoteAgentModel}
|
|
||||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
|
||||||
placeholder={activeConfig.model || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((model) => (
|
|
||||||
<SelectItem key={model.id} value={model.id}>
|
|
||||||
{model.name || model.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showApiKey && (
|
{showApiKey && (
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-foreground">
|
<p className="text-sm text-foreground">
|
||||||
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
|
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSwitchToRowboat}
|
onClick={handleSwitchToRowboat}
|
||||||
|
|
@ -221,76 +221,6 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 min-w-0">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">
|
|
||||||
Meeting Notes Model
|
|
||||||
</label>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.meetingNotesModel}
|
|
||||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
|
||||||
placeholder={activeConfig.model || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.meetingNotesModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full truncate">
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((model) => (
|
|
||||||
<SelectItem key={model.id} value={model.id}>
|
|
||||||
{model.name || model.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 min-w-0">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">
|
|
||||||
Track Block Model
|
|
||||||
</label>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.liveNoteAgentModel}
|
|
||||||
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
|
||||||
placeholder={activeConfig.model || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full truncate">
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((model) => (
|
|
||||||
<SelectItem key={model.id} value={model.id}>
|
|
||||||
{model.name || model.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showApiKey && (
|
{showApiKey && (
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
|
|
@ -66,22 +66,22 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
// Inline upsell callout dismissed
|
// Inline upsell callout dismissed
|
||||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||||
|
|
||||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
// Composio/Gmail state (used when signed in with Rowboat account)
|
||||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||||
const [useComposioForGoogle] = useState(false)
|
|
||||||
const [gmailConnected, setGmailConnected] = useState(false)
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
const [gmailLoading, setGmailLoading] = useState(true)
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||||
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||||
|
|
||||||
const [useComposioForGoogleCalendar] = useState(false)
|
// Composio/Google Calendar state
|
||||||
|
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||||
|
|
||||||
const updateProviderConfig = useCallback(
|
const updateProviderConfig = useCallback(
|
||||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[provider]: { ...prev[provider], ...updates },
|
[provider]: { ...prev[provider], ...updates },
|
||||||
|
|
@ -123,8 +123,25 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
setProvidersLoading(false)
|
setProvidersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.)
|
async function loadComposioForGoogleFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||||
|
setUseComposioForGoogle(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadComposioForGoogleCalendarFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||||
|
setUseComposioForGoogleCalendar(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
loadProviders()
|
loadProviders()
|
||||||
|
loadComposioForGoogleFlag()
|
||||||
|
loadComposioForGoogleCalendarFlag()
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Load LLM models catalog on open
|
// Load LLM models catalog on open
|
||||||
|
|
@ -418,8 +435,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||||
const model = activeConfig.model.trim()
|
const model = activeConfig.model.trim()
|
||||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
|
||||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
flavor: llmProvider,
|
flavor: llmProvider,
|
||||||
|
|
@ -428,8 +443,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
knowledgeGraphModel,
|
knowledgeGraphModel,
|
||||||
meetingNotesModel,
|
|
||||||
liveNoteAgentModel,
|
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -446,7 +459,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
setTestState({ status: "error", error: "Connection test failed" })
|
setTestState({ status: "error", error: "Connection test failed" })
|
||||||
toast.error("Connection test failed")
|
toast.error("Connection test failed")
|
||||||
}
|
}
|
||||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
|
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
|
||||||
|
|
||||||
// Check connection status for all providers
|
// Check connection status for all providers
|
||||||
const refreshAllStatuses = useCallback(async () => {
|
const refreshAllStatuses = useCallback(async () => {
|
||||||
|
|
@ -522,7 +535,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
|
|
||||||
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
||||||
if (event.provider === 'rowboat' && event.success) {
|
if (event.provider === 'rowboat' && event.success) {
|
||||||
// (Composio Gmail/Calendar flag re-check removed — sync was deleted.)
|
// Re-check composio flags now that the account is connected
|
||||||
|
try {
|
||||||
|
const [googleResult, calendarResult] = await Promise.all([
|
||||||
|
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||||
|
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||||
|
])
|
||||||
|
setUseComposioForGoogle(googleResult.enabled)
|
||||||
|
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to re-check composio flags:', error)
|
||||||
|
}
|
||||||
setCurrentStep(2) // Go to Connect Accounts
|
setCurrentStep(2) // Go to Connect Accounts
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -582,20 +605,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
// Connect to a provider
|
// Connect to a provider
|
||||||
const handleConnect = useCallback(async (provider: string) => {
|
const handleConnect = useCallback(async (provider: string) => {
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
|
||||||
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
|
|
||||||
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
|
|
||||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
|
||||||
if (isSignedIntoRowboat) {
|
|
||||||
await startConnect('google')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGoogleClientIdOpen(true)
|
setGoogleClientIdOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await startConnect(provider)
|
await startConnect(provider)
|
||||||
}, [startConnect, providerStates])
|
}, [startConnect])
|
||||||
|
|
||||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
|
|
|
||||||
|
|
@ -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 posthog from 'posthog-js'
|
||||||
import * as analytics from '@/lib/analytics'
|
import * as analytics from '@/lib/analytics'
|
||||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||||
|
|
@ -21,66 +21,36 @@ interface SearchResult {
|
||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchType = 'knowledge' | 'chat'
|
type SearchType = 'knowledge' | 'chat'
|
||||||
|
|
||||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||||
if (section === 'knowledge') return ['knowledge']
|
if (section === 'knowledge') return ['knowledge']
|
||||||
return ['chat']
|
return ['chat'] // "tasks" tab maps to chat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retained for any remaining programmatic Copilot entry points (background-agent
|
interface SearchDialogProps {
|
||||||
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
|
|
||||||
export type CommandPaletteContext = {
|
|
||||||
path: string
|
|
||||||
lineNumber: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandPaletteMention = {
|
|
||||||
path: string
|
|
||||||
displayName: string
|
|
||||||
lineNumber?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSelectFile: (path: string) => void
|
onSelectFile: (path: string) => void
|
||||||
onSelectRun: (runId: string) => void
|
onSelectRun: (runId: string) => void
|
||||||
// Overrides the sidebar-section default for the initial scope (e.g. the
|
|
||||||
// knowledge view opens search scoped to knowledge).
|
|
||||||
defaultScope?: SearchType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({
|
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSelectFile,
|
|
||||||
onSelectRun,
|
|
||||||
defaultScope,
|
|
||||||
}: CommandPaletteProps) {
|
|
||||||
const { activeSection } = useSidebarSection()
|
const { activeSection } = useSidebarSection()
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [results, setResults] = useState<SearchResult[]>([])
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||||
() => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))
|
() => new Set(activeTabToTypes(activeSection))
|
||||||
)
|
)
|
||||||
const debouncedQuery = useDebounce(query, 250)
|
const debouncedQuery = useDebounce(query, 250)
|
||||||
|
|
||||||
// Sync filters and clear query when the dialog opens.
|
// Sync filter preselection when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery('')
|
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||||
setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)))
|
|
||||||
}
|
}
|
||||||
}, [open, activeSection, defaultScope])
|
}, [open, activeSection])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
searchInputRef.current?.focus()
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const toggleType = useCallback((type: SearchType) => {
|
const toggleType = useCallback((type: SearchType) => {
|
||||||
setActiveTypes(new Set([type]))
|
setActiveTypes(new Set([type]))
|
||||||
|
|
@ -106,15 +76,20 @@ export function CommandPalette({
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Search failed:', err)
|
console.error('Search failed:', err)
|
||||||
if (!cancelled) setResults([])
|
if (!cancelled) {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsSearching(false)
|
if (!cancelled) {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [debouncedQuery, activeTypes])
|
}, [debouncedQuery, activeTypes])
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setQuery('')
|
setQuery('')
|
||||||
|
|
@ -144,7 +119,6 @@ export function CommandPalette({
|
||||||
className="top-[20%] translate-y-0"
|
className="top-[20%] translate-y-0"
|
||||||
>
|
>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
ref={searchInputRef}
|
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
|
|
@ -222,19 +196,17 @@ function FilterToggle({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground'
|
? "bg-accent text-accent-foreground"
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
{label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back-compat export: thin alias to CommandPalette.
|
|
||||||
export const SearchDialog = CommandPalette
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -25,9 +24,8 @@ import { useTheme } from "@/contexts/theme-context"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { AccountSettings } from "@/components/settings/account-settings"
|
import { AccountSettings } from "@/components/settings/account-settings"
|
||||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
|
||||||
|
|
||||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
|
||||||
|
|
||||||
interface TabConfig {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -45,10 +43,10 @@ const tabs: TabConfig[] = [
|
||||||
description: "Manage your Rowboat account",
|
description: "Manage your Rowboat account",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "connections",
|
id: "connected-accounts",
|
||||||
label: "Connections",
|
label: "Connected Accounts",
|
||||||
icon: Plug,
|
icon: Plug,
|
||||||
description: "Manage accounts and tools",
|
description: "Manage connected services",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "models",
|
id: "models",
|
||||||
|
|
@ -71,18 +69,18 @@ const tabs: TabConfig[] = [
|
||||||
path: "config/security.json",
|
path: "config/security.json",
|
||||||
description: "Configure allowed shell commands",
|
description: "Configure allowed shell commands",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "code-mode",
|
|
||||||
label: "Code Mode",
|
|
||||||
icon: Terminal,
|
|
||||||
description: "Delegate coding tasks to Claude Code or Codex",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "appearance",
|
id: "appearance",
|
||||||
label: "Appearance",
|
label: "Appearance",
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Customize the look and feel",
|
description: "Customize the look and feel",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tools",
|
||||||
|
label: "Tools Library",
|
||||||
|
icon: Wrench,
|
||||||
|
description: "Browse and enable toolkits",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "note-tagging",
|
id: "note-tagging",
|
||||||
label: "Note Tagging",
|
label: "Note Tagging",
|
||||||
|
|
@ -90,93 +88,10 @@ const tabs: TabConfig[] = [
|
||||||
path: "config/tags.json",
|
path: "config/tags.json",
|
||||||
description: "Configure tags for notes and emails",
|
description: "Configure tags for notes and emails",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "help",
|
|
||||||
label: "Help",
|
|
||||||
icon: HelpCircle,
|
|
||||||
description: "Get help and support",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
/** Optional trigger element. Omit when controlling `open` externally. */
|
children: React.ReactNode
|
||||||
children?: React.ReactNode
|
|
||||||
/** Tab to open on when the dialog is shown. Defaults to "account". */
|
|
||||||
defaultTab?: ConfigTab
|
|
||||||
/** Controlled open state. When provided, the dialog is fully controlled. */
|
|
||||||
open?: boolean
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Help & Support tab ---
|
|
||||||
|
|
||||||
function HelpSettings() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium">Help & Support</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start gap-3 h-auto py-3"
|
|
||||||
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
|
|
||||||
>
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
|
|
||||||
<Bug className="size-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="text-sm font-medium">Report a bug</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start gap-3 h-auto py-3"
|
|
||||||
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
|
|
||||||
>
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
|
|
||||||
<MessageCircle className="size-4 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="text-sm font-medium">Join our Discord</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Chat with the community</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start gap-3 h-auto py-3"
|
|
||||||
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
|
|
||||||
>
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
|
||||||
<Mail className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="text-sm font-medium">Contact us</span>
|
|
||||||
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
|
||||||
<a
|
|
||||||
href="https://www.rowboatlabs.com/terms-of-service"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</a>
|
|
||||||
<span>·</span>
|
|
||||||
<a
|
|
||||||
href="https://www.rowboatlabs.com/privacy-policy"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Theme option for Appearance tab ---
|
// --- Theme option for Appearance tab ---
|
||||||
|
|
@ -211,7 +126,7 @@ function ThemeOption({
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppearanceSettings() {
|
function AppearanceSettings() {
|
||||||
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -241,50 +156,6 @@ function AppearanceSettings() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-3">Chat</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
|
||||||
Choose where chat sits when another pane is open
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<ThemeOption
|
|
||||||
label="Chat right"
|
|
||||||
icon={PanelRight}
|
|
||||||
isSelected={chatPanePlacement === "right"}
|
|
||||||
onClick={() => setChatPanePlacement("right")}
|
|
||||||
/>
|
|
||||||
<ThemeOption
|
|
||||||
label="Chat middle"
|
|
||||||
icon={MessageCircle}
|
|
||||||
isSelected={chatPanePlacement === "middle"}
|
|
||||||
onClick={() => setChatPanePlacement("middle")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
|
||||||
Choose how much width chat gets when another pane is open
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<ThemeOption
|
|
||||||
label="Chat smaller"
|
|
||||||
icon={MessageCircle}
|
|
||||||
isSelected={chatPaneSize === "chat-smaller"}
|
|
||||||
onClick={() => setChatPaneSize("chat-smaller")}
|
|
||||||
/>
|
|
||||||
<ThemeOption
|
|
||||||
label="Chat equal"
|
|
||||||
icon={Monitor}
|
|
||||||
isSelected={chatPaneSize === "chat-equal"}
|
|
||||||
onClick={() => setChatPaneSize("chat-equal")}
|
|
||||||
/>
|
|
||||||
<ThemeOption
|
|
||||||
label="Chat bigger"
|
|
||||||
icon={PanelRight}
|
|
||||||
isSelected={chatPaneSize === "chat-bigger"}
|
|
||||||
onClick={() => setChatPaneSize("chat-bigger")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -322,27 +193,17 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||||
"openai-compatible": "http://localhost:1234/v1",
|
"openai-compatible": "http://localhost:1234/v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderModelConfig = {
|
|
||||||
apiKey: string
|
|
||||||
baseURL: string
|
|
||||||
models: string[]
|
|
||||||
knowledgeGraphModel: string
|
|
||||||
meetingNotesModel: string
|
|
||||||
liveNoteAgentModel: string
|
|
||||||
autoPermissionDecisionModel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
|
||||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
|
|
@ -368,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||||
|
|
||||||
const updateConfig = useCallback(
|
const updateConfig = useCallback(
|
||||||
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { ...prev[prov], ...updates },
|
[prov]: { ...prev[prov], ...updates },
|
||||||
|
|
@ -441,9 +302,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||||
models: savedModels,
|
models: savedModels,
|
||||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||||
meetingNotesModel: e.meetingNotesModel || "",
|
|
||||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
|
||||||
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -460,9 +318,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||||
models: activeModels.length > 0 ? activeModels : [""],
|
models: activeModels.length > 0 ? activeModels : [""],
|
||||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
|
||||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
|
||||||
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -536,9 +391,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
model: allModels[0] || "",
|
model: allModels[0] || "",
|
||||||
models: allModels,
|
models: allModels,
|
||||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
|
||||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
|
||||||
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -571,9 +423,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
model: allModels[0],
|
model: allModels[0],
|
||||||
models: allModels,
|
models: allModels,
|
||||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
|
||||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
|
||||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
|
||||||
})
|
})
|
||||||
setDefaultProvider(prov)
|
setDefaultProvider(prov)
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
|
@ -603,9 +452,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
parsed.model = defModels[0] || ""
|
parsed.model = defModels[0] || ""
|
||||||
parsed.models = defModels
|
parsed.models = defModels
|
||||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
|
||||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
|
||||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
|
||||||
}
|
}
|
||||||
await window.ipc.invoke("workspace:writeFile", {
|
await window.ipc.invoke("workspace:writeFile", {
|
||||||
path: "config/models.json",
|
path: "config/models.json",
|
||||||
|
|
@ -613,7 +459,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
})
|
})
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
|
||||||
}))
|
}))
|
||||||
setTestState({ status: "idle" })
|
setTestState({ status: "idle" })
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
|
@ -803,108 +649,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meeting notes model */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.meetingNotesModel}
|
|
||||||
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
|
||||||
placeholder={primaryModel || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.meetingNotesModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={m.id}>
|
|
||||||
{m.name || m.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track block model */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.liveNoteAgentModel}
|
|
||||||
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
|
|
||||||
placeholder={primaryModel || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={m.id}>
|
|
||||||
{m.name || m.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto-permission model */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
|
||||||
{modelsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : showModelInput ? (
|
|
||||||
<Input
|
|
||||||
value={activeConfig.autoPermissionDecisionModel}
|
|
||||||
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
|
||||||
placeholder={primaryModel || "Enter model"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
|
||||||
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
||||||
{modelsForProvider.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={m.id}>
|
|
||||||
{m.name || m.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
|
|
@ -1748,255 +1492,11 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Code Mode Settings ---
|
|
||||||
|
|
||||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
|
||||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
|
||||||
|
|
||||||
function AgentStatusRow({
|
|
||||||
name,
|
|
||||||
installLink,
|
|
||||||
signInCommand,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
installLink: string
|
|
||||||
signInCommand: string
|
|
||||||
status: AgentStatus | null
|
|
||||||
}) {
|
|
||||||
const ready = status?.installed && status?.signedIn
|
|
||||||
const needsSignInOnly = status?.installed && !status?.signedIn
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
|
||||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium">{name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
|
||||||
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
|
|
||||||
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
|
||||||
Installed
|
|
||||||
</span>
|
|
||||||
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
|
||||||
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
|
||||||
Signed in
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ready ? (
|
|
||||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
|
||||||
Ready
|
|
||||||
</span>
|
|
||||||
) : needsSignInOnly ? (
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">
|
|
||||||
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={installLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-primary hover:underline shrink-0"
|
|
||||||
>
|
|
||||||
Install & sign in
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|
||||||
const [enabled, setEnabled] = useState(false)
|
|
||||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
|
||||||
const [statusLoading, setStatusLoading] = useState(false)
|
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
|
||||||
setStatusLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
|
||||||
setStatus(result)
|
|
||||||
} catch {
|
|
||||||
setStatus(null)
|
|
||||||
} finally {
|
|
||||||
setStatusLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dialogOpen) return
|
|
||||||
let cancelled = false
|
|
||||||
async function load() {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
|
||||||
if (!cancelled) {
|
|
||||||
setEnabled(result.enabled)
|
|
||||||
setApprovalPolicy(result.approvalPolicy ?? 'ask')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setEnabled(false)
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
loadStatus()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [dialogOpen, loadStatus])
|
|
||||||
|
|
||||||
const handleToggle = useCallback(async (next: boolean) => {
|
|
||||||
setSaving(true)
|
|
||||||
setEnabled(next)
|
|
||||||
try {
|
|
||||||
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
|
|
||||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
|
||||||
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
|
||||||
} catch {
|
|
||||||
setEnabled(!next)
|
|
||||||
toast.error("Failed to update code mode")
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}, [approvalPolicy])
|
|
||||||
|
|
||||||
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
|
|
||||||
const prev = approvalPolicy
|
|
||||||
setSaving(true)
|
|
||||||
setApprovalPolicy(next)
|
|
||||||
try {
|
|
||||||
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
|
|
||||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
|
||||||
} catch {
|
|
||||||
setApprovalPolicy(prev)
|
|
||||||
toast.error("Failed to update approval policy")
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}, [enabled, approvalPolicy])
|
|
||||||
|
|
||||||
const anyReady = status?.claude.installed && status?.claude.signedIn
|
|
||||||
|| status?.codex.installed && status?.codex.signedIn
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
||||||
<Loader2 className="size-4 animate-spin mr-2" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<p>
|
|
||||||
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
|
||||||
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
|
||||||
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
|
|
||||||
and streams its work — tool calls, file diffs, and approvals — back into chat.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
|
||||||
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
|
|
||||||
<button
|
|
||||||
onClick={() => { void loadStatus() }}
|
|
||||||
disabled={statusLoading}
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
|
||||||
Re-check
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<AgentStatusRow
|
|
||||||
name="Claude Code"
|
|
||||||
installLink="https://claude.ai/code"
|
|
||||||
signInCommand="claude login"
|
|
||||||
status={status?.claude ?? null}
|
|
||||||
/>
|
|
||||||
<AgentStatusRow
|
|
||||||
name="Codex"
|
|
||||||
installLink="https://developers.openai.com/codex/cli"
|
|
||||||
signInCommand="codex login"
|
|
||||||
status={status?.codex ?? null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium">Enable code mode</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{enabled && (
|
|
||||||
<div className="rounded-md border px-3 py-3 space-y-2">
|
|
||||||
<div className="text-sm font-medium">Approvals</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
How the coding agent checks in before changing files or running commands. You always see
|
|
||||||
everything it does in the timeline — this only controls the prompts.
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={approvalPolicy}
|
|
||||||
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="ask">Ask every time</SelectItem>
|
|
||||||
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
|
|
||||||
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
|
|
||||||
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
|
|
||||||
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{enabled && status && !anyReady && (
|
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
|
||||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
|
||||||
<div className="text-amber-900 dark:text-amber-200">
|
|
||||||
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
|
|
||||||
account, then click Re-check.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main Settings Dialog ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const open = controlledOpen ?? internalOpen
|
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
|
||||||
const setOpen = useCallback((next: boolean) => {
|
|
||||||
if (onOpenChange) onOpenChange(next)
|
|
||||||
else setInternalOpen(next)
|
|
||||||
}, [onOpenChange])
|
|
||||||
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
|
|
||||||
const [content, setContent] = useState("")
|
const [content, setContent] = useState("")
|
||||||
const [originalContent, setOriginalContent] = useState("")
|
const [originalContent, setOriginalContent] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -2004,11 +1504,6 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [rowboatConnected, setRowboatConnected] = useState(false)
|
const [rowboatConnected, setRowboatConnected] = useState(false)
|
||||||
|
|
||||||
// Reset to the requested default tab each time the dialog is opened
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) setActiveTab(defaultTab)
|
|
||||||
}, [open, defaultTab])
|
|
||||||
|
|
||||||
// Check if user is signed in to Rowboat
|
// Check if user is signed in to Rowboat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -2034,7 +1529,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
|
||||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -2100,7 +1595,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -2142,21 +1637,11 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||||
{activeTab === "account" ? (
|
{activeTab === "account" ? (
|
||||||
<AccountSettings dialogOpen={open} />
|
<AccountSettings dialogOpen={open} />
|
||||||
) : activeTab === "connections" ? (
|
) : activeTab === "connected-accounts" ? (
|
||||||
<div className="space-y-6">
|
<ConnectedAccountsSettings dialogOpen={open} />
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold">Primary accounts</h4>
|
|
||||||
<ConnectedAccountsSettings dialogOpen={open} />
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold">Library</h4>
|
|
||||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : activeTab === "models" ? (
|
) : activeTab === "models" ? (
|
||||||
rowboatConnected
|
rowboatConnected
|
||||||
? <RowboatModelSettings dialogOpen={open} />
|
? <RowboatModelSettings dialogOpen={open} />
|
||||||
|
|
@ -2165,10 +1650,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
<NoteTaggingSettings dialogOpen={open} />
|
<NoteTaggingSettings dialogOpen={open} />
|
||||||
) : activeTab === "appearance" ? (
|
) : activeTab === "appearance" ? (
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
) : activeTab === "help" ? (
|
) : activeTab === "tools" ? (
|
||||||
<HelpSettings />
|
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||||
) : activeTab === "code-mode" ? (
|
|
||||||
<CodeModeSettings dialogOpen={open} />
|
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
||||||
|
|
@ -17,44 +17,11 @@ import {
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { useBilling } from "@/hooks/useBilling"
|
import { useBilling } from "@/hooks/useBilling"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
|
|
||||||
|
|
||||||
interface AccountSettingsProps {
|
interface AccountSettingsProps {
|
||||||
dialogOpen: boolean
|
dialogOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPlanName(plan: string | null | undefined) {
|
|
||||||
if (!plan) return 'No Plan'
|
|
||||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreditUsageBar({ label, bucket, helper }: {
|
|
||||||
label: string
|
|
||||||
bucket: BillingUsageBucket
|
|
||||||
helper?: string
|
|
||||||
}) {
|
|
||||||
const pct = bucket.sanctionedCredits > 0
|
|
||||||
? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100)))
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
|
||||||
{helper ? <p className="text-[11px] text-muted-foreground">{helper}</p> : null}
|
|
||||||
</div>
|
|
||||||
<p className="shrink-0 text-xs font-medium tabular-nums">
|
|
||||||
{pct}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
const [connectionLoading, setConnectionLoading] = useState(true)
|
const [connectionLoading, setConnectionLoading] = useState(true)
|
||||||
|
|
@ -62,7 +29,6 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
const [connecting, setConnecting] = useState(false)
|
const [connecting, setConnecting] = useState(false)
|
||||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||||
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
const checkConnection = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -197,7 +163,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium capitalize">
|
<p className="text-sm font-medium capitalize">
|
||||||
{formatPlanName(billing.subscriptionPlan)}
|
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
|
||||||
</p>
|
</p>
|
||||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||||
|
|
@ -214,17 +180,9 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 border-t pt-3">
|
|
||||||
<CreditUsageBar label="Plan usage" bucket={billing.monthly} />
|
|
||||||
<CreditUsageBar
|
|
||||||
label="Daily use"
|
|
||||||
bucket={billing.daily}
|
|
||||||
helper="Daily usage resets at 00:00 UTC"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
||||||
|
|
@ -245,15 +203,15 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!hasPaidSubscription}
|
disabled={!billing?.subscriptionPlan}
|
||||||
onClick={() => appUrl && window.open(appUrl)}
|
onClick={() => appUrl && window.open(appUrl)}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-3" />
|
<ExternalLink className="size-3" />
|
||||||
Manage in Stripe
|
Manage in Stripe
|
||||||
</Button>
|
</Button>
|
||||||
{!hasPaidSubscription && (
|
{!billing?.subscriptionPlan && (
|
||||||
<p className="text-[11px] text-muted-foreground">Upgrade to a paid plan first</p>
|
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider}
|
key={provider}
|
||||||
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
|
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -52,7 +52,16 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => c.handleReconnect(provider)}
|
onClick={() => {
|
||||||
|
if (provider === 'google') {
|
||||||
|
c.setGoogleClientIdDescription(
|
||||||
|
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||||
|
)
|
||||||
|
c.setGoogleClientIdOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.startConnect(provider)
|
||||||
|
}}
|
||||||
className="h-7 px-3 text-xs"
|
className="h-7 px-3 text-xs"
|
||||||
>
|
>
|
||||||
Reconnect
|
Reconnect
|
||||||
|
|
@ -119,15 +128,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
{/* Email & Calendar Section */}
|
{/* Email & Calendar Section */}
|
||||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 pt-1 pb-0.5">
|
<div className="px-4 py-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Email & Calendar
|
Email & Calendar
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{c.useComposioForGoogle ? (
|
{c.useComposioForGoogle ? (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||||
<Mail className="size-4" />
|
<Mail className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -174,9 +183,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||||
)}
|
)}
|
||||||
{c.useComposioForGoogleCalendar && (
|
{c.useComposioForGoogleCalendar && (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||||
<Calendar className="size-4" />
|
<Calendar className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -220,14 +229,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator className="my-2" />
|
<Separator className="my-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Meeting Notes Section */}
|
{/* Meeting Notes Section */}
|
||||||
{c.providers.includes('fireflies-ai') && (
|
{c.providers.includes('fireflies-ai') && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 pt-1 pb-0.5">
|
<div className="px-4 py-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Meeting Notes
|
Meeting Notes
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rowboat-tabbar flex flex-1 self-stretch min-w-0',
|
'flex flex-1 self-stretch min-w-0',
|
||||||
layout === 'scroll'
|
layout === 'scroll'
|
||||||
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||||
: 'overflow-hidden'
|
: 'overflow-hidden'
|
||||||
|
|
@ -57,7 +57,7 @@ export function TabBar<T>({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSwitchTab(tabId)}
|
onClick={() => onSwitchTab(tabId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rowboat-tab titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||||
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
|
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-background text-foreground'
|
? 'bg-background text-foreground'
|
||||||
|
|
|
||||||
|
|
@ -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-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group"
|
data-slot="sidebar-group"
|
||||||
data-sidebar="group"
|
data-sidebar="group"
|
||||||
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu"
|
data-slot="sidebar-menu"
|
||||||
data-sidebar="menu"
|
data-sidebar="menu"
|
||||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
import * as React from "react"
|
||||||
|
|
||||||
export type Theme = "light" | "dark" | "system"
|
export type Theme = "light" | "dark" | "system"
|
||||||
export type ChatPanePlacement = "right" | "middle"
|
|
||||||
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
|
|
||||||
|
|
||||||
type ThemeContextProps = {
|
type ThemeContextProps = {
|
||||||
theme: Theme
|
theme: Theme
|
||||||
resolvedTheme: "light" | "dark"
|
resolvedTheme: "light" | "dark"
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void
|
||||||
chatPanePlacement: ChatPanePlacement
|
|
||||||
setChatPanePlacement: (placement: ChatPanePlacement) => void
|
|
||||||
chatPaneSize: ChatPaneSize
|
|
||||||
setChatPaneSize: (size: ChatPaneSize) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
||||||
|
|
||||||
const STORAGE_KEY = "rowboat-theme"
|
const STORAGE_KEY = "rowboat-theme"
|
||||||
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
|
|
||||||
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
|
|
||||||
|
|
||||||
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
|
|
||||||
return value === "right" || value === "middle"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChatPaneSize(value: string | null): value is ChatPaneSize {
|
|
||||||
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemTheme(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "light"
|
if (typeof window === "undefined") return "light"
|
||||||
|
|
@ -55,16 +39,6 @@ export function ThemeProvider({
|
||||||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||||
return stored || defaultTheme
|
return stored || defaultTheme
|
||||||
})
|
})
|
||||||
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
|
|
||||||
if (typeof window === "undefined") return "right"
|
|
||||||
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
|
|
||||||
return isChatPanePlacement(stored) ? stored : "right"
|
|
||||||
})
|
|
||||||
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
|
|
||||||
if (typeof window === "undefined") return "chat-smaller"
|
|
||||||
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
|
|
||||||
return isChatPaneSize(stored) ? stored : "chat-smaller"
|
|
||||||
})
|
|
||||||
|
|
||||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||||
if (theme === "system") return getSystemTheme()
|
if (theme === "system") return getSystemTheme()
|
||||||
|
|
@ -102,27 +76,13 @@ export function ThemeProvider({
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
|
|
||||||
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
|
|
||||||
setChatPanePlacementState(placement)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
|
|
||||||
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
|
|
||||||
setChatPaneSizeState(size)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const contextValue = React.useMemo<ThemeContextProps>(
|
const contextValue = React.useMemo<ThemeContextProps>(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
chatPanePlacement,
|
|
||||||
setChatPanePlacement,
|
|
||||||
chatPaneSize,
|
|
||||||
setChatPaneSize,
|
|
||||||
}),
|
}),
|
||||||
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
|
[theme, resolvedTheme, setTheme]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||||
import { blocks } from '@x/shared'
|
import { blocks } from '@x/shared'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { extractConferenceLink } from '../lib/calendar-event'
|
|
||||||
|
|
||||||
function formatTime(dateStr: string): string {
|
function formatTime(dateStr: string): string {
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
|
|
@ -41,6 +40,25 @@ function getTimeRange(event: blocks.CalendarEvent): string {
|
||||||
return `${startTime} \u2013 ${endTime}`
|
return `${startTime} \u2013 ${endTime}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a video conference link from raw Google Calendar event JSON.
|
||||||
|
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||||
|
* to conferenceLink if already set.
|
||||||
|
*/
|
||||||
|
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||||
|
// Check conferenceData.entryPoints for video entry
|
||||||
|
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||||
|
if (confData?.entryPoints) {
|
||||||
|
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||||
|
if (video?.uri) return video.uri
|
||||||
|
}
|
||||||
|
// Check hangoutLink (Google Meet shortcut)
|
||||||
|
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||||
|
// Fall back to conferenceLink if present
|
||||||
|
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
interface ResolvedEvent {
|
interface ResolvedEvent {
|
||||||
event: blocks.CalendarEvent
|
event: blocks.CalendarEvent
|
||||||
loaded: blocks.CalendarEvent | null
|
loaded: blocks.CalendarEvent | null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { mergeAttributes, Node } from '@tiptap/react'
|
import { mergeAttributes, Node } from '@tiptap/react'
|
||||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
|
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
|
||||||
import { blocks } from '@x/shared'
|
import { blocks } from '@x/shared'
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useTheme } from '@/contexts/theme-context'
|
import { useTheme } from '@/contexts/theme-context'
|
||||||
|
|
@ -11,47 +11,17 @@ function formatEmailDate(dateStr: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
if (isNaN(d.getTime())) return dateStr
|
if (isNaN(d.getTime())) return dateStr
|
||||||
const now = new Date()
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||||
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
||||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr
|
return dateStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFullDate(dateStr: string): string {
|
/** Extract just the name part from "Name <email>" format */
|
||||||
try {
|
function senderFirstName(from: string): string {
|
||||||
const d = new Date(dateStr)
|
const name = from.replace(/<.*>/, '').trim()
|
||||||
if (isNaN(d.getTime())) return dateStr
|
return name.split(/\s+/)[0] || name
|
||||||
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +
|
|
||||||
', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
||||||
} catch {
|
|
||||||
return dateStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractName(from: string): string {
|
|
||||||
const match = from.match(/^([^<]+)</)
|
|
||||||
if (match) return match[1].trim()
|
|
||||||
const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
|
|
||||||
return username.replace(/\b\w/g, c => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitial(from: string): string {
|
|
||||||
const name = extractName(from)
|
|
||||||
return (name[0] || '?').toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const GMAIL_AVATAR_COLORS = [
|
|
||||||
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
|
|
||||||
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
|
|
||||||
]
|
|
||||||
|
|
||||||
function avatarColor(from: string): string {
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0
|
|
||||||
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -60,307 +30,7 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shared: expanded email body used by both block types ---
|
// --- Email Block ---
|
||||||
|
|
||||||
function EmailExpandedBody({
|
|
||||||
config,
|
|
||||||
resolvedTheme,
|
|
||||||
}: {
|
|
||||||
config: blocks.EmailBlock
|
|
||||||
resolvedTheme: string
|
|
||||||
}) {
|
|
||||||
const [draftBody, setDraftBody] = useState(config.draft_response || '')
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDraftBody(config.draft_response || '')
|
|
||||||
}, [config.draft_response])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bodyRef.current) {
|
|
||||||
bodyRef.current.style.height = 'auto'
|
|
||||||
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
|
||||||
}
|
|
||||||
}, [draftBody])
|
|
||||||
|
|
||||||
const draftWithAssistant = useCallback(() => {
|
|
||||||
let prompt = draftBody
|
|
||||||
? `Help me refine this draft response to an email`
|
|
||||||
: `Help me draft a response to this email`
|
|
||||||
if (config.threadId) {
|
|
||||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
|
||||||
}
|
|
||||||
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
|
|
||||||
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
|
|
||||||
window.__pendingEmailDraft = { prompt }
|
|
||||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
|
||||||
}, [config, draftBody])
|
|
||||||
|
|
||||||
const copyDraft = useCallback(() => {
|
|
||||||
navigator.clipboard.writeText(draftBody).then(() => {
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}).catch(() => {
|
|
||||||
const el = document.createElement('textarea')
|
|
||||||
el.value = draftBody
|
|
||||||
document.body.appendChild(el)
|
|
||||||
el.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(el)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}, [draftBody])
|
|
||||||
|
|
||||||
const gmailUrl = config.threadId
|
|
||||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
const initial = config.from ? getInitial(config.from) : '?'
|
|
||||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
|
||||||
const hasDraft = !!config.draft_response
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="email-gmail-expanded">
|
|
||||||
{config.subject && (
|
|
||||||
<div className="email-gmail-exp-subject">{config.subject}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="email-gmail-exp-meta">
|
|
||||||
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
|
||||||
<div className="email-gmail-exp-meta-right">
|
|
||||||
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
|
|
||||||
<div className="email-gmail-exp-to-date">
|
|
||||||
{config.to && <span>to {config.to}</span>}
|
|
||||||
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="email-gmail-exp-body">{config.latest_email}</div>
|
|
||||||
|
|
||||||
{config.past_summary && (
|
|
||||||
<div className="email-gmail-exp-history">
|
|
||||||
<div className="email-gmail-exp-history-label">Earlier conversation</div>
|
|
||||||
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasDraft && (
|
|
||||||
<div className="email-gmail-reply-row">
|
|
||||||
{gmailUrl && (
|
|
||||||
<button
|
|
||||||
className="email-gmail-btn"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={13} />
|
|
||||||
Open in Gmail
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
|
|
||||||
>
|
|
||||||
<MessageSquare size={13} />
|
|
||||||
Draft with Rowboat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasDraft && (
|
|
||||||
<div className="email-gmail-compose">
|
|
||||||
<div className="email-gmail-compose-to">
|
|
||||||
<span className="email-gmail-compose-to-label">Reply</span>
|
|
||||||
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
key={resolvedTheme}
|
|
||||||
ref={bodyRef}
|
|
||||||
className="email-gmail-compose-body"
|
|
||||||
value={draftBody}
|
|
||||||
onChange={(e) => setDraftBody(e.target.value)}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
placeholder="Write your reply..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="email-gmail-compose-footer">
|
|
||||||
<button
|
|
||||||
className="email-gmail-btn email-gmail-btn-primary"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
|
|
||||||
>
|
|
||||||
<MessageSquare size={13} />
|
|
||||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="email-gmail-btn"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => { e.stopPropagation(); copyDraft() }}
|
|
||||||
>
|
|
||||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
|
||||||
{copied ? 'Copied!' : 'Copy draft'}
|
|
||||||
</button>
|
|
||||||
{gmailUrl && (
|
|
||||||
<button
|
|
||||||
className="email-gmail-btn"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={13} />
|
|
||||||
Open in Gmail
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Multi-email inbox block (language-emails) ---
|
|
||||||
|
|
||||||
function EmailsBlockView({ node, deleteNode }: {
|
|
||||||
node: { attrs: Record<string, unknown> }
|
|
||||||
deleteNode: () => void
|
|
||||||
}) {
|
|
||||||
const raw = node.attrs.data as string
|
|
||||||
let config: blocks.EmailsBlock | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
|
|
||||||
} catch { /* fallback below */ }
|
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme()
|
|
||||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
|
|
||||||
|
|
||||||
if (!config || config.emails.length === 0) {
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
|
|
||||||
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
|
|
||||||
<div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
|
|
||||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
|
|
||||||
|
|
||||||
{config.title && (
|
|
||||||
<div className="email-inbox-title">{config.title}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="email-inbox-list">
|
|
||||||
{config.emails.map((email, i) => {
|
|
||||||
const isExpanded = expandedIndex === i
|
|
||||||
const senderName = email.from ? extractName(email.from) : 'Unknown'
|
|
||||||
const initial = email.from ? getInitial(email.from) : '?'
|
|
||||||
const color = email.from ? avatarColor(email.from) : '#5f6368'
|
|
||||||
const snippet = email.summary
|
|
||||||
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
|
|
||||||
{/* Collapsed row */}
|
|
||||||
<div
|
|
||||||
className="email-inbox-row-header"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
|
||||||
|
|
||||||
<div className="email-inbox-content">
|
|
||||||
<div className="email-inbox-top-row">
|
|
||||||
<span className="email-inbox-sender">{senderName}</span>
|
|
||||||
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="email-inbox-bottom-row">
|
|
||||||
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
|
|
||||||
{snippet && (
|
|
||||||
<span className="email-inbox-snippet">
|
|
||||||
{email.subject ? ` — ${snippet}` : snippet}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChevronDown
|
|
||||||
size={14}
|
|
||||||
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="email-inbox-expanded-wrap">
|
|
||||||
<EmailExpandedBody
|
|
||||||
config={email}
|
|
||||||
resolvedTheme={resolvedTheme}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EmailsBlockExtension = Node.create({
|
|
||||||
name: 'emailsBlock',
|
|
||||||
group: 'block',
|
|
||||||
atom: true,
|
|
||||||
selectable: true,
|
|
||||||
draggable: false,
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return { data: { default: '{}' } }
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [{
|
|
||||||
tag: 'pre',
|
|
||||||
priority: 61,
|
|
||||||
getAttrs(element) {
|
|
||||||
const code = element.querySelector('code')
|
|
||||||
if (!code) return false
|
|
||||||
if ((code.className || '').includes('language-emails')) {
|
|
||||||
return { data: code.textContent || '{}' }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
|
||||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(EmailsBlockView)
|
|
||||||
},
|
|
||||||
|
|
||||||
addStorage() {
|
|
||||||
return {
|
|
||||||
markdown: {
|
|
||||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
|
||||||
state.write('```emails\n' + node.attrs.data + '\n```')
|
|
||||||
state.closeBlock(node)
|
|
||||||
},
|
|
||||||
parse: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Single email block (language-email, backward compat) ---
|
|
||||||
|
|
||||||
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||||
node: { attrs: Record<string, unknown> }
|
node: { attrs: Record<string, unknown> }
|
||||||
|
|
@ -372,57 +42,194 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||||
} catch { /* fallback below */ }
|
} catch {
|
||||||
|
// fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDraft = !!config?.draft_response
|
||||||
|
const hasPastSummary = !!config?.past_summary
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
|
|
||||||
void updateAttributes // available for future per-email draft persistence
|
// Local draft state for editing
|
||||||
|
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||||
|
const [emailExpanded, setEmailExpanded] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Sync draft from external changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||||
|
setDraftBody(parsed.draft_response || '')
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [raw])
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
if (bodyRef.current) {
|
||||||
|
bodyRef.current.style.height = 'auto'
|
||||||
|
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
}, [draftBody])
|
||||||
|
|
||||||
|
const commitDraft = useCallback((newBody: string) => {
|
||||||
|
try {
|
||||||
|
const current = JSON.parse(raw) as Record<string, unknown>
|
||||||
|
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [raw, updateAttributes])
|
||||||
|
|
||||||
|
const draftWithAssistant = useCallback(() => {
|
||||||
|
if (!config) return
|
||||||
|
let prompt = draftBody
|
||||||
|
? `Help me refine this draft response to an email`
|
||||||
|
: `Help me draft a response to this email`
|
||||||
|
if (config.threadId) {
|
||||||
|
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||||
|
}
|
||||||
|
prompt += `.\n\n`
|
||||||
|
prompt += `**From:** ${config.from || 'Unknown'}\n`
|
||||||
|
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
|
||||||
|
if (draftBody) {
|
||||||
|
prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||||
|
}
|
||||||
|
window.__pendingEmailDraft = { prompt }
|
||||||
|
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||||
|
}, [config, draftBody])
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||||
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
|
<div className="email-block-card email-block-error">
|
||||||
|
<Mail size={16} />
|
||||||
|
<span>Invalid email block</span>
|
||||||
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderName = config.from ? extractName(config.from) : 'Unknown'
|
const gmailUrl = config.threadId
|
||||||
const initial = config.from ? getInitial(config.from) : '?'
|
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
: null
|
||||||
const snippet = config.summary
|
|
||||||
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
|
// Build summary: use explicit summary, or auto-generate from sender + subject
|
||||||
|
const summary = config.summary
|
||||||
|
|| (config.from && config.subject
|
||||||
|
? `${senderFirstName(config.from)} reached out about ${config.subject}`
|
||||||
|
: config.subject || 'New email')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||||
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
|
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button>
|
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
{/* Header: Email badge */}
|
||||||
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
|
<div className="email-block-badge">
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
|
<Mail size={13} />
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
Email
|
||||||
>
|
|
||||||
<div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div>
|
|
||||||
<div className="email-gmail-content">
|
|
||||||
<div className="email-gmail-top-row">
|
|
||||||
<span className="email-gmail-sender">{senderName}</span>
|
|
||||||
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="email-gmail-bottom-row">
|
|
||||||
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
|
|
||||||
{snippet && <span className="email-gmail-snippet">{config.subject ? ` — ${snippet}` : snippet}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{/* Summary */}
|
||||||
<EmailExpandedBody
|
<div className="email-block-summary">{summary}</div>
|
||||||
config={config}
|
|
||||||
resolvedTheme={resolvedTheme}
|
{/* Expandable email details */}
|
||||||
/>
|
<button
|
||||||
|
className="email-block-expand-btn"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||||
|
{emailExpanded ? 'Hide email' : 'Show email'}
|
||||||
|
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
|
||||||
|
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{emailExpanded && (
|
||||||
|
<div className="email-block-email-details">
|
||||||
|
<div className="email-block-message">
|
||||||
|
<div className="email-block-message-header">
|
||||||
|
<div className="email-block-sender-info">
|
||||||
|
<div className="email-block-sender-row">
|
||||||
|
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
|
||||||
|
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||||
|
</div>
|
||||||
|
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="email-block-message-body">{config.latest_email}</div>
|
||||||
|
</div>
|
||||||
|
{hasPastSummary && (
|
||||||
|
<div className="email-block-context-section">
|
||||||
|
<div className="email-block-context-label">Earlier conversation</div>
|
||||||
|
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Draft section */}
|
||||||
|
{hasDraft && (
|
||||||
|
<div className="email-block-draft-section">
|
||||||
|
<div className="email-block-draft-label">Draft reply</div>
|
||||||
|
<textarea
|
||||||
|
key={resolvedTheme}
|
||||||
|
ref={bodyRef}
|
||||||
|
className="email-draft-block-body-input"
|
||||||
|
value={draftBody}
|
||||||
|
onChange={(e) => setDraftBody(e.target.value)}
|
||||||
|
onBlur={() => commitDraft(draftBody)}
|
||||||
|
placeholder="Write your reply..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="email-block-actions">
|
||||||
|
<button
|
||||||
|
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||||
|
onClick={draftWithAssistant}
|
||||||
|
>
|
||||||
|
<MessageSquare size={13} />
|
||||||
|
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||||
|
</button>
|
||||||
|
{hasDraft && (
|
||||||
|
<button
|
||||||
|
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(draftBody).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback for Electron contexts where clipboard API may fail
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = draftBody
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||||
|
{copied ? 'Copied!' : 'Copy draft'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{gmailUrl && (
|
||||||
|
<button
|
||||||
|
className="email-block-gmail-btn"
|
||||||
|
onClick={() => window.open(gmailUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
Open in Gmail
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
|
|
@ -436,7 +243,9 @@ export const EmailBlockExtension = Node.create({
|
||||||
draggable: false,
|
draggable: false,
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return { data: { default: '{}' } }
|
return {
|
||||||
|
data: { default: '{}' },
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
|
|
@ -447,7 +256,7 @@ export const EmailBlockExtension = Node.create({
|
||||||
const code = element.querySelector('code')
|
const code = element.querySelector('code')
|
||||||
if (!code) return false
|
if (!code) return false
|
||||||
const cls = code.className || ''
|
const cls = code.className || ''
|
||||||
if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) {
|
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
|
||||||
return { data: code.textContent || '{}' }
|
return { data: code.textContent || '{}' }
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { mergeAttributes, Node } from '@tiptap/react'
|
import { mergeAttributes, Node } from '@tiptap/react'
|
||||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { X, ExternalLink } from 'lucide-react'
|
import { X, ExternalLink } from 'lucide-react'
|
||||||
import { Tweet } from 'react-tweet'
|
|
||||||
import { blocks } from '@x/shared'
|
import { blocks } from '@x/shared'
|
||||||
|
|
||||||
function getEmbedUrl(provider: string, url: string): string | null {
|
function getEmbedUrl(provider: string, url: string): string | null {
|
||||||
|
|
@ -25,28 +24,6 @@ function getEmbedUrl(provider: string, url: string): string | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTweetId(url: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url)
|
|
||||||
const hostname = parsed.hostname
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^www\./, '')
|
|
||||||
.replace(/^mobile\./, '')
|
|
||||||
if (hostname !== 'twitter.com' && hostname !== 'x.com') return null
|
|
||||||
|
|
||||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
|
||||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
||||||
if ((segments[i] === 'status' || segments[i] === 'statuses') && /^\d+$/.test(segments[i + 1])) {
|
|
||||||
return segments[i + 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||||
const raw = node.attrs.data as string
|
const raw = node.attrs.data as string
|
||||||
let config: blocks.EmbedBlock | null = null
|
let config: blocks.EmbedBlock | null = null
|
||||||
|
|
@ -68,7 +45,6 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tweetId = extractTweetId(config.url)
|
|
||||||
const embedUrl = getEmbedUrl(config.provider, config.url)
|
const embedUrl = getEmbedUrl(config.provider, config.url)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -81,14 +57,7 @@ function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
{config.provider === 'tweet' && tweetId ? (
|
{embedUrl ? (
|
||||||
<div
|
|
||||||
className="embed-block-tweet-shell"
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Tweet id={tweetId} />
|
|
||||||
</div>
|
|
||||||
) : embedUrl ? (
|
|
||||||
<div className="embed-block-iframe-container">
|
<div className="embed-block-iframe-container">
|
||||||
<iframe
|
<iframe
|
||||||
src={embedUrl}
|
src={embedUrl}
|
||||||
|
|
|
||||||
|
|
@ -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 { Node, mergeAttributes } from '@tiptap/react'
|
||||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
|
import { InputRule, inputRules } from '@tiptap/pm/inputrules'
|
||||||
|
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||||
|
|
||||||
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
|
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
|
||||||
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
|
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
|
||||||
|
|
@ -25,12 +26,9 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const matchIndex = match.index ?? 0
|
const matchIndex = match.index ?? 0
|
||||||
const matchText = match[0] ?? ''
|
const matchText = match[0] ?? ''
|
||||||
const rawLink = match[1]?.trim() ?? ''
|
const rawPath = match[1]?.trim() ?? ''
|
||||||
const { label } = splitWikiAlias(rawLink)
|
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||||
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
|
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
|
||||||
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
|
|
||||||
const isHeadingOnlyLink = !basePath && Boolean(heading)
|
|
||||||
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
|
|
||||||
|
|
||||||
if (matchIndex > lastIndex) {
|
if (matchIndex > lastIndex) {
|
||||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
|
||||||
|
|
@ -38,8 +36,7 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
||||||
|
|
||||||
if (isValidPath) {
|
if (isValidPath) {
|
||||||
const el = document.createElement('wiki-link')
|
const el = document.createElement('wiki-link')
|
||||||
el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
|
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
|
||||||
if (label) el.setAttribute('data-label', label)
|
|
||||||
fragment.appendChild(el)
|
fragment.appendChild(el)
|
||||||
} else {
|
} else {
|
||||||
fragment.appendChild(document.createTextNode(matchText))
|
fragment.appendChild(document.createTextNode(matchText))
|
||||||
|
|
@ -84,9 +81,6 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
||||||
path: {
|
path: {
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
label: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -94,36 +88,30 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'wiki-link[data-path]',
|
tag: 'wiki-link[data-path]',
|
||||||
getAttrs: (element: Element) => ({
|
getAttrs: (element) => ({
|
||||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||||
label: (element as HTMLElement).getAttribute('data-label'),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a[data-type="wiki-link"]',
|
tag: 'a[data-type="wiki-link"]',
|
||||||
getAttrs: (element: Element) => ({
|
getAttrs: (element) => ({
|
||||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||||
label: (element as HTMLElement).getAttribute('data-label'),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ node, HTMLAttributes }) {
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
|
const label = wikiLabel(node.attrs.path) || node.attrs.path
|
||||||
return [
|
return [
|
||||||
'a',
|
'a',
|
||||||
mergeAttributes(
|
mergeAttributes(HTMLAttributes, {
|
||||||
HTMLAttributes,
|
'data-type': 'wiki-link',
|
||||||
{
|
'data-path': node.attrs.path,
|
||||||
'data-type': 'wiki-link',
|
'href': '#',
|
||||||
'data-path': node.attrs.path,
|
'class': 'wiki-link',
|
||||||
'href': '#',
|
'aria-label': node.attrs.path,
|
||||||
'class': 'wiki-link',
|
}),
|
||||||
'aria-label': node.attrs.path,
|
|
||||||
},
|
|
||||||
node.attrs.label ? { 'data-label': node.attrs.label } : {}
|
|
||||||
),
|
|
||||||
label,
|
label,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -133,8 +121,7 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
||||||
markdown: {
|
markdown: {
|
||||||
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
|
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
|
||||||
const path = node.attrs.path ?? ''
|
const path = node.attrs.path ?? ''
|
||||||
const label = (node.attrs as { label?: string }).label
|
state.write(`[[${path}]]`)
|
||||||
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
|
|
||||||
},
|
},
|
||||||
parse: {
|
parse: {
|
||||||
updateDOM(element: HTMLElement) {
|
updateDOM(element: HTMLElement) {
|
||||||
|
|
@ -145,29 +132,23 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addInputRules() {
|
addProseMirrorPlugins() {
|
||||||
const onCreate = this.options.onCreate
|
const onCreate = this.options.onCreate
|
||||||
return [
|
const rules = [
|
||||||
new InputRule({
|
new InputRule(wikiLinkInputRegex, (state, match, start, end) => {
|
||||||
find: wikiLinkInputRegex,
|
const rawPath = match[1]?.trim()
|
||||||
handler: ({ state, range, match }) => {
|
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||||
const rawLink = match[1]?.trim()
|
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||||
const { label } = splitWikiAlias(rawLink ?? '')
|
if (state.selection.$from.parent.type.spec.code) return null
|
||||||
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
|
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
||||||
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
|
|
||||||
const isHeadingOnlyLink = !basePath && Boolean(heading)
|
|
||||||
if (
|
|
||||||
!normalizedPath
|
|
||||||
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
|
|
||||||
) return null
|
|
||||||
if (state.selection.$from.parent.type.spec.code) return null
|
|
||||||
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
|
||||||
|
|
||||||
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
|
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
|
const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath }))
|
||||||
onCreate?.(finalPath)
|
onCreate?.(finalPath)
|
||||||
},
|
return tr
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
return [inputRules({ rules })]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
3
apps/x/apps/renderer/src/global.d.ts
vendored
3
apps/x/apps/renderer/src/global.d.ts
vendored
|
|
@ -35,9 +35,8 @@ declare global {
|
||||||
};
|
};
|
||||||
electronUtils: {
|
electronUtils: {
|
||||||
getPathForFile: (file: File) => string;
|
getPathForFile: (file: File) => string;
|
||||||
getZoomFactor: () => number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|
@ -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 { useEffect } from 'react'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identifies the user in PostHog when signed into Rowboat,
|
* Identifies the user in PostHog when signed into Rowboat,
|
||||||
|
|
@ -18,7 +17,7 @@ export function useAnalyticsIdentity() {
|
||||||
// Identify if Rowboat account is connected
|
// Identify if Rowboat account is connected
|
||||||
const rowboat = config.rowboat
|
const rowboat = config.rowboat
|
||||||
if (rowboat?.connected && rowboat?.userId) {
|
if (rowboat?.connected && rowboat?.userId) {
|
||||||
identifyUser(rowboat.userId)
|
posthog.identify(rowboat.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider connection flags
|
// Set provider connection flags
|
||||||
|
|
@ -59,29 +58,15 @@ export function useAnalyticsIdentity() {
|
||||||
// Listen for OAuth connect/disconnect events to update identity
|
// Listen for OAuth connect/disconnect events to update identity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||||
if (event.provider !== 'rowboat') {
|
if (!event.success) return
|
||||||
// Other providers: just toggle the connection flag
|
|
||||||
if (event.success) {
|
// If Rowboat provider connected, identify user
|
||||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
if (event.provider === 'rowboat' && event.userId) {
|
||||||
}
|
posthog.identify(event.userId)
|
||||||
return
|
posthog.people.set({ signed_in: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rowboat sign-in
|
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||||
if (event.success) {
|
|
||||||
if (event.userId) {
|
|
||||||
identifyUser(event.userId)
|
|
||||||
}
|
|
||||||
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
|
||||||
posthog.capture('user_signed_in')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rowboat sign-out — flip flags, capture, and reset distinct_id so
|
|
||||||
// future events on this device don't get attributed to the prior user.
|
|
||||||
posthog.people.set({ signed_in: false, rowboat_connected: false })
|
|
||||||
posthog.capture('user_signed_out')
|
|
||||||
resetAnalyticsIdentity()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return cleanup
|
return cleanup
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import type { BillingInfo } from '@x/shared/dist/billing.js'
|
|
||||||
|
interface BillingInfo {
|
||||||
|
userEmail: string | null
|
||||||
|
userId: string | null
|
||||||
|
subscriptionPlan: string | null
|
||||||
|
subscriptionStatus: string | null
|
||||||
|
trialExpiresAt: string | null
|
||||||
|
sanctionedCredits: number
|
||||||
|
availableCredits: number
|
||||||
|
}
|
||||||
|
|
||||||
export function useBilling(isRowboatConnected: boolean) {
|
export function useBilling(isRowboatConnected: boolean) {
|
||||||
const [billing, setBilling] = useState<BillingInfo | null>(null)
|
const [billing, setBilling] = useState<BillingInfo | null>(null)
|
||||||
|
|
|
||||||
|
|
@ -38,21 +38,16 @@ export function useConnectors(active: boolean) {
|
||||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
// Composio/Gmail state
|
||||||
// and never flipped — the IPC that used to set them is gone. The setters
|
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||||
// remain so the legacy Composio-Gmail handlers below still type-check,
|
|
||||||
// but those handlers are no longer reachable in the UI (the gating
|
|
||||||
// condition `useComposioForGoogle` stays false).
|
|
||||||
// TODO follow-up: drop these flags entirely and prune the dead UI branches
|
|
||||||
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
|
|
||||||
const [useComposioForGoogle] = useState(false)
|
|
||||||
const [gmailConnected, setGmailConnected] = useState(false)
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
const [gmailLoading, setGmailLoading] = useState(false)
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
|
|
||||||
const [useComposioForGoogleCalendar] = useState(false)
|
// Composio/Google Calendar state
|
||||||
|
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
|
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||||
|
|
||||||
// Load available providers on mount
|
// Load available providers on mount
|
||||||
|
|
@ -72,7 +67,28 @@ export function useConnectors(active: boolean) {
|
||||||
loadProviders()
|
loadProviders()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
|
// Re-check composio-for-google flags when active
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
async function loadComposioForGoogleFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||||
|
setUseComposioForGoogle(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadComposioForGoogleCalendarFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||||
|
setUseComposioForGoogleCalendar(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadComposioForGoogleFlag()
|
||||||
|
loadComposioForGoogleCalendarFlag()
|
||||||
|
}, [active])
|
||||||
|
|
||||||
// Load Granola config
|
// Load Granola config
|
||||||
const refreshGranolaConfig = useCallback(async () => {
|
const refreshGranolaConfig = useCallback(async () => {
|
||||||
|
|
@ -330,22 +346,13 @@ export function useConnectors(active: boolean) {
|
||||||
|
|
||||||
const handleConnect = useCallback(async (provider: string) => {
|
const handleConnect = useCallback(async (provider: string) => {
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
|
||||||
// the webapp in the browser, no BYOK modal. Main process detects
|
|
||||||
// signed-in via isSignedIn() when oauth:connect arrives without creds.
|
|
||||||
// Falls back to the BYOK modal for not-signed-in users.
|
|
||||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
|
||||||
if (isSignedIntoRowboat) {
|
|
||||||
await startConnect('google')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGoogleClientIdDescription(undefined)
|
setGoogleClientIdDescription(undefined)
|
||||||
setGoogleClientIdOpen(true)
|
setGoogleClientIdOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await startConnect(provider)
|
await startConnect(provider)
|
||||||
}, [startConnect, providerStates])
|
}, [startConnect])
|
||||||
|
|
||||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
|
|
@ -354,25 +361,6 @@ export function useConnectors(active: boolean) {
|
||||||
startConnect('google', { clientId, clientSecret })
|
startConnect('google', { clientId, clientSecret })
|
||||||
}, [startConnect])
|
}, [startConnect])
|
||||||
|
|
||||||
// Reconnect flow used by the "Reconnect" button. Mirrors handleConnect's
|
|
||||||
// rowboat-vs-BYOK branching for Google so signed-in users don't get the
|
|
||||||
// client-ID modal — they just re-run the managed-credentials browser flow.
|
|
||||||
const handleReconnect = useCallback(async (provider: string) => {
|
|
||||||
if (provider === 'google') {
|
|
||||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
|
||||||
if (isSignedIntoRowboat) {
|
|
||||||
await startConnect('google')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGoogleClientIdDescription(
|
|
||||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
|
||||||
)
|
|
||||||
setGoogleClientIdOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await startConnect(provider)
|
|
||||||
}, [startConnect, providerStates])
|
|
||||||
|
|
||||||
const handleDisconnect = useCallback(async (provider: string) => {
|
const handleDisconnect = useCallback(async (provider: string) => {
|
||||||
setProviderStates(prev => ({
|
setProviderStates(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -497,6 +485,19 @@ export function useConnectors(active: boolean) {
|
||||||
toast.success(`Connected to ${displayName}`)
|
toast.success(`Connected to ${displayName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'rowboat') {
|
||||||
|
try {
|
||||||
|
const [googleResult, calendarResult] = await Promise.all([
|
||||||
|
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||||
|
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||||
|
])
|
||||||
|
setUseComposioForGoogle(googleResult.enabled)
|
||||||
|
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to re-check composio flags:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
refreshAllStatuses()
|
refreshAllStatuses()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -553,7 +554,6 @@ export function useConnectors(active: boolean) {
|
||||||
providerStatus,
|
providerStatus,
|
||||||
hasProviderError,
|
hasProviderError,
|
||||||
handleConnect,
|
handleConnect,
|
||||||
handleReconnect,
|
|
||||||
handleDisconnect,
|
handleDisconnect,
|
||||||
startConnect,
|
startConnect,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,5 @@
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
|
|
||||||
let appVersion: string | undefined
|
|
||||||
let apiUrl: string | undefined
|
|
||||||
|
|
||||||
function appVersionProperties(): Record<string, string> {
|
|
||||||
return appVersion ? { app_version: appVersion } : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
|
|
||||||
appVersion = props.appVersion?.trim() || undefined
|
|
||||||
apiUrl = props.apiUrl?.trim() || undefined
|
|
||||||
|
|
||||||
const eventProperties = appVersionProperties()
|
|
||||||
if (Object.keys(eventProperties).length > 0) {
|
|
||||||
posthog.register(eventProperties)
|
|
||||||
}
|
|
||||||
|
|
||||||
const personProperties = {
|
|
||||||
...(apiUrl ? { api_url: apiUrl } : {}),
|
|
||||||
...eventProperties,
|
|
||||||
}
|
|
||||||
if (Object.keys(personProperties).length > 0) {
|
|
||||||
posthog.people.set(personProperties)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
|
||||||
posthog.identify(userId, {
|
|
||||||
...properties,
|
|
||||||
...appVersionProperties(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetAnalyticsIdentity() {
|
|
||||||
posthog.reset()
|
|
||||||
configureAnalyticsContext({ appVersion, apiUrl })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chatSessionCreated(runId: string) {
|
export function chatSessionCreated(runId: string) {
|
||||||
posthog.capture('chat_session_created', { run_id: runId })
|
posthog.capture('chat_session_created', { run_id: runId })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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