mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
Compare commits
No commits in common. "main" and "v0.5.1" have entirely different histories.
130 changed files with 3240 additions and 13683 deletions
51
.github/workflows/electron-build.yml
vendored
51
.github/workflows/electron-build.yml
vendored
|
|
@ -16,14 +16,14 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -39,17 +39,17 @@ jobs:
|
|||
node -e "
|
||||
const fs = require('fs');
|
||||
const version = '${{ steps.version.outputs.version }}';
|
||||
|
||||
|
||||
// Update apps/x/package.json
|
||||
const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8'));
|
||||
rootPackage.version = version;
|
||||
fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n');
|
||||
|
||||
|
||||
// Update apps/x/apps/main/package.json
|
||||
const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8'));
|
||||
mainPackage.version = version;
|
||||
fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n');
|
||||
|
||||
|
||||
console.log('Updated version to:', version);
|
||||
"
|
||||
|
||||
|
|
@ -61,25 +61,25 @@ jobs:
|
|||
# Create a temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
|
||||
# Create keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
|
||||
# Decode and import certificate
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
|
||||
security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
|
||||
|
||||
# Allow codesign to access the keychain
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
|
||||
# Add keychain to search list
|
||||
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
|
||||
|
||||
|
||||
# Verify certificate was imported
|
||||
security find-identity -v "$KEYCHAIN_PATH"
|
||||
|
||||
|
||||
# Clean up certificate file
|
||||
rm -f $RUNNER_TEMP/certificate.p12
|
||||
|
||||
|
|
@ -111,7 +111,6 @@ jobs:
|
|||
with:
|
||||
name: distributables
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
|
|
@ -122,14 +121,14 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -145,17 +144,17 @@ jobs:
|
|||
node -e "
|
||||
const fs = require('fs');
|
||||
const version = '${{ steps.version.outputs.version }}';
|
||||
|
||||
|
||||
// Update apps/x/package.json
|
||||
const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8'));
|
||||
rootPackage.version = version;
|
||||
fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n');
|
||||
|
||||
|
||||
// Update apps/x/apps/main/package.json
|
||||
const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8'));
|
||||
mainPackage.version = version;
|
||||
fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n');
|
||||
|
||||
|
||||
console.log('Updated version to:', version);
|
||||
"
|
||||
|
||||
|
|
@ -176,7 +175,6 @@ jobs:
|
|||
with:
|
||||
name: distributables-linux
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
|
|
@ -187,14 +185,14 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||
|
||||
|
|
@ -212,17 +210,17 @@ jobs:
|
|||
node -e "
|
||||
const fs = require('fs');
|
||||
const version = '${{ steps.version.outputs.version }}';
|
||||
|
||||
|
||||
// Update apps/x/package.json
|
||||
const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8'));
|
||||
rootPackage.version = version;
|
||||
fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n');
|
||||
|
||||
|
||||
// Update apps/x/apps/main/package.json
|
||||
const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8'));
|
||||
mainPackage.version = version;
|
||||
fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n');
|
||||
|
||||
|
||||
console.log('Updated version to:', version);
|
||||
"
|
||||
|
||||
|
|
@ -243,5 +241,4 @@ jobs:
|
|||
with:
|
||||
name: distributables-windows
|
||||
path: apps/x/apps/main/out/make/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
|
||||
## 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).
|
||||
|
|
@ -103,7 +101,6 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as
|
|||
| `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 |
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t
|
|||
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.
|
||||
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
|
||||
|
||||
When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block.
|
||||
|
||||
|
|
@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message:
|
|||
- 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.
|
||||
1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
|
||||
2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites.
|
||||
3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first.
|
||||
4. Never modify YAML frontmatter — that's owned by the user and the runtime.
|
||||
5. End with a 1-2 sentence summary stored as `lastRunSummary`.
|
||||
|
|
@ -115,7 +115,7 @@ Backend (main process)
|
|||
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
|
||||
└─ Builtin tool │ │
|
||||
run-live-note-agent ────┘ ▼
|
||||
file-readText / -edit
|
||||
workspace-readFile / -edit
|
||||
│
|
||||
▼
|
||||
body region(s) rewritten on disk
|
||||
|
|
@ -249,7 +249,7 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know
|
|||
- 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.
|
||||
- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
|
||||
- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1.
|
||||
|
||||
---
|
||||
|
|
@ -316,7 +316,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd
|
|||
- **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.
|
||||
- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
|
||||
|
||||
Three branches by `trigger`:
|
||||
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills.
|
||||
|
|
|
|||
|
|
@ -10,13 +10,11 @@
|
|||
*/
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||
// The banner defines __import_meta_url at the top of the bundle,
|
||||
// and we use define to replace all import.meta.url references with it.
|
||||
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
||||
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['./dist/main.js'],
|
||||
|
|
@ -38,7 +36,6 @@ await esbuild.build({
|
|||
// 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 ?? ''),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ module.exports = {
|
|||
description: 'AI coworker with memory',
|
||||
name: `Rowboat-win32-${arch}`,
|
||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
@ -67,9 +66,7 @@ module.exports = {
|
|||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -80,9 +77,7 @@ module.exports = {
|
|||
name: `Rowboat-linux`,
|
||||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -13,8 +13,6 @@
|
|||
"make": "electron-forge make"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
|
|||
import { URL } from 'url';
|
||||
|
||||
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
||||
export const DEFAULT_PORT = 8080;
|
||||
export const PORT_RANGE_SIZE = 10;
|
||||
const DEFAULT_PORT = 8080;
|
||||
|
||||
/** Escape HTML special characters to prevent XSS */
|
||||
function escapeHtml(str: string): string {
|
||||
|
|
@ -20,8 +19,13 @@ export interface AuthServerResult {
|
|||
port: number;
|
||||
}
|
||||
|
||||
function tryBindPort(
|
||||
port: number,
|
||||
/**
|
||||
* Create a local HTTP server to handle OAuth callback
|
||||
* Listens on http://localhost:8080/oauth/callback
|
||||
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
||||
*/
|
||||
export function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -33,7 +37,7 @@ function tryBindPort(
|
|||
}
|
||||
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
|
||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
|
|
@ -92,10 +96,8 @@ function tryBindPort(
|
|||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
server.close();
|
||||
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
||||
// Signal caller to try next port
|
||||
reject(Object.assign(new Error(err.code), { code: err.code }));
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
|
|
@ -103,51 +105,3 @@ function tryBindPort(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local HTTP server to handle OAuth callback.
|
||||
*
|
||||
* Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
|
||||
* thrown if it cannot be bound. This is the right behaviour for any provider
|
||||
* whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those
|
||||
* callers must keep using the exact port they've handed to the provider.
|
||||
*
|
||||
* Opt into `{ fallback: true }` only when the caller is prepared to use the
|
||||
* port returned in `AuthServerResult` (i.e. the redirect URI is built from the
|
||||
* actual bound port, not hard-coded). With fallback enabled, scans `port`
|
||||
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
||||
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
||||
* Hyper-V/WSL2 reserve the port).
|
||||
*/
|
||||
export async function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: { fallback?: boolean } = {},
|
||||
): Promise<AuthServerResult> {
|
||||
const fallback = opts.fallback === true;
|
||||
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
||||
|
||||
for (let p = port; p <= limit; p++) {
|
||||
try {
|
||||
return await tryBindPort(p, onCallback);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
||||
console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`);
|
||||
continue;
|
||||
}
|
||||
if (!fallback) {
|
||||
const reason = code === 'EACCES' || code === 'EADDRINUSE'
|
||||
? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
|
||||
: (err instanceof Error ? err.message : String(err));
|
||||
throw new Error(reason);
|
||||
}
|
||||
throw new Error(
|
||||
`No available port found in range ${port}–${limit}. Free a port in that range and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — loop always returns or throws — but satisfies TypeScript
|
||||
throw new Error(`No available port found in range ${port}–${limit}.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron';
|
||||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
|
@ -31,10 +31,6 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
|||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
|
|
@ -52,7 +48,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -456,7 +452,6 @@ export function setupIpcHandlers() {
|
|||
return {
|
||||
installationId: getInstallationId(),
|
||||
apiUrl: API_URL,
|
||||
appVersion: app.getVersion(),
|
||||
};
|
||||
},
|
||||
'workspace:getRoot': async () => {
|
||||
|
|
@ -499,24 +494,6 @@ export function setupIpcHandlers() {
|
|||
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 {};
|
||||
|
|
@ -531,17 +508,12 @@ export function setupIpcHandlers() {
|
|||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
return { success: true };
|
||||
},
|
||||
'codeRun:resolvePermission': async (_event, args) => {
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
registry.resolve(args.requestId, args.decision);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:provideHumanInput': async (_event, args) => {
|
||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||
return { success: true };
|
||||
|
|
@ -640,20 +612,6 @@ export function setupIpcHandlers() {
|
|||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled };
|
||||
},
|
||||
'codeMode:getConfig': async () => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
|
||||
},
|
||||
'codeMode:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
|
||||
invalidateCopilotInstructionsCache();
|
||||
return { success: true };
|
||||
},
|
||||
'codeMode:checkAgentStatus': async () => {
|
||||
return await checkCodeModeAgentStatus();
|
||||
},
|
||||
'granola:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled });
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@ import started from "electron-squirrel-startup";
|
|||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -52,7 +51,6 @@ import {
|
|||
extractDeepLinkFromArgv,
|
||||
setMainWindowForDeepLinks,
|
||||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -221,7 +219,6 @@ function createWindow() {
|
|||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
|
|
@ -354,11 +351,6 @@ app.whenReady().then(async () => {
|
|||
registerConsumer(backgroundTaskEventConsumer);
|
||||
initEventProcessor();
|
||||
|
||||
// If the stored Google grant predates a scope change (only old scopes),
|
||||
// disconnect it now so the user re-connects with the current scopes before
|
||||
// any Google sync runs against the stale grant.
|
||||
await disconnectGoogleIfScopesStale();
|
||||
|
||||
// start gmail sync
|
||||
initGmailSync();
|
||||
|
||||
|
|
@ -418,12 +410,6 @@ app.on("before-quit", () => {
|
|||
stopWorkspaceWatcher();
|
||||
stopRunsWatcher();
|
||||
stopServicesWatcher();
|
||||
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
||||
try {
|
||||
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
||||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||
|
|
@ -18,9 +17,7 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
|
|||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
|
||||
function buildRedirectUri(port: number): string {
|
||||
return `http://localhost:${port}/oauth/callback`;
|
||||
}
|
||||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||
|
||||
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
||||
|
|
@ -117,15 +114,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get or create OAuth configuration for a provider.
|
||||
* `redirectUri` is required for DCR providers — it is the actual callback URI
|
||||
* (including port) that was just bound, so the registration and auth URL stay in sync.
|
||||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(
|
||||
provider: string,
|
||||
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
|
||||
credentialsOverride?: { clientId: string; clientSecret: string },
|
||||
): Promise<Configuration> {
|
||||
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
||||
const config = await getProviderConfig(provider);
|
||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
|
|
@ -157,7 +148,7 @@ async function getProviderConfiguration(
|
|||
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
|
||||
const clientRepo = getClientRegistrationRepo();
|
||||
const existingRegistration = await clientRepo.getClientRegistration(provider);
|
||||
|
||||
|
||||
if (existingRegistration) {
|
||||
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
|
||||
return await oauthClient.discoverConfiguration(
|
||||
|
|
@ -166,21 +157,18 @@ async function getProviderConfiguration(
|
|||
);
|
||||
}
|
||||
|
||||
// Register new client with the actual redirect URI (port already bound)
|
||||
// Register new client
|
||||
const scopes = config.scopes || [];
|
||||
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
||||
config.discovery.issuer,
|
||||
[redirectUri],
|
||||
[REDIRECT_URI],
|
||||
scopes
|
||||
);
|
||||
|
||||
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
|
||||
const boundPort = new URL(redirectUri).port
|
||||
? parseInt(new URL(redirectUri).port, 10)
|
||||
: DEFAULT_CALLBACK_PORT;
|
||||
await clientRepo.saveClientRegistration(provider, registration, boundPort);
|
||||
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
|
||||
|
||||
|
||||
// Save registration for future use
|
||||
await clientRepo.saveClientRegistration(provider, registration);
|
||||
console.log(`[OAuth] ${provider}: DCR registration saved`);
|
||||
|
||||
return oauthConfig;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -188,7 +176,7 @@ async function getProviderConfiguration(
|
|||
if (config.client.mode !== 'static') {
|
||||
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
||||
}
|
||||
|
||||
|
||||
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
||||
const { clientId, clientSecret } = await resolveClientCredentials();
|
||||
return oauthClient.createStaticConfiguration(
|
||||
|
|
@ -201,37 +189,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
|
||||
*/
|
||||
|
|
@ -268,188 +225,154 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
}
|
||||
}
|
||||
|
||||
// For static-client providers (Google BYOK) the redirect URI is pre-registered
|
||||
// at the OAuth provider console on a fixed port — we must not scan.
|
||||
// For DCR providers, resolveStartPort handles the re-registration trap.
|
||||
const isStaticClient = providerConfig.client.mode === 'static';
|
||||
const startPort = isStaticClient
|
||||
? DEFAULT_CALLBACK_PORT
|
||||
: await resolveStartPort(provider, getClientRegistrationRepo());
|
||||
// Get or create OAuth configuration
|
||||
const config = await getProviderConfiguration(provider, credentials);
|
||||
|
||||
// --- Callback server ---
|
||||
// Declare `state` before the closure so the callback can close over its binding.
|
||||
// The variable is assigned below, before shell.openExternal, so it is always
|
||||
// set by the time any browser request arrives.
|
||||
let state = '';
|
||||
// Generate PKCE codes
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
const state = oauthClient.generateState();
|
||||
|
||||
// Get scopes from config
|
||||
const scopes = providerConfig.scopes || [];
|
||||
|
||||
// Store flow state
|
||||
activeFlows.set(state, { codeVerifier, provider, config });
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
});
|
||||
|
||||
// Create callback server
|
||||
let callbackHandled = false;
|
||||
|
||||
const { server, port: boundPort } = await createAuthServer(
|
||||
startPort,
|
||||
async (callbackUrl) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
const receivedState = callbackUrl.searchParams.get('state');
|
||||
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');
|
||||
}
|
||||
|
||||
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 { server } = await createAuthServer(8080, async (callbackUrl) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
const receivedState = callbackUrl.searchParams.get('state');
|
||||
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');
|
||||
}
|
||||
|
||||
const redirectUri = buildRedirectUri(boundPort);
|
||||
const config = await getProviderConfiguration(provider, redirectUri, credentials);
|
||||
const flow = activeFlows.get(state);
|
||||
if (!flow || flow.provider !== provider) {
|
||||
throw new Error('Invalid OAuth flow state');
|
||||
}
|
||||
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
state = oauthClient.generateState();
|
||||
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
|
||||
);
|
||||
|
||||
const scopes = providerConfig.scopes || [];
|
||||
activeFlows.set(state, { codeVerifier, provider, config });
|
||||
// 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,
|
||||
});
|
||||
|
||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
state,
|
||||
});
|
||||
|
||||
// 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');
|
||||
// Trigger immediate sync for relevant providers
|
||||
if (provider === 'google') {
|
||||
triggerGmailSync();
|
||||
triggerCalendarSync();
|
||||
} else if (provider === 'fireflies-ai') {
|
||||
triggerFirefliesSync();
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
activeFlow = {
|
||||
provider,
|
||||
state,
|
||||
server,
|
||||
cleanupTimeout,
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Open in system browser (shares cookies/sessions with user's regular browser)
|
||||
shell.openExternal(authUrl.toString());
|
||||
|
||||
return { success: true };
|
||||
} catch (setupError) {
|
||||
// Post-bind setup failed — close the server so the port is released and
|
||||
// a retry isn't blocked by our own zombie listener.
|
||||
server.close();
|
||||
if (state) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
console.error('OAuth connection failed:', error);
|
||||
return {
|
||||
|
|
@ -508,7 +431,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
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) });
|
||||
const res = await fetch(revokeUrl, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
||||
}
|
||||
|
|
@ -532,81 +455,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup migration for Google scope changes. When a connected Google grant was
|
||||
* issued before a scope was added (e.g. old installs on gmail.readonly that
|
||||
* never received gmail.modify), invalidate it so the user is prompted to
|
||||
* reconnect and re-grant with the current scopes. The currently-requested
|
||||
* scopes in the provider config are the source of truth: a grant missing any
|
||||
* of them is treated as stale.
|
||||
*
|
||||
* We revoke + clear the stale token but DELIBERATELY keep the provider entry
|
||||
* with an `error` set rather than calling disconnectProvider (which deletes the
|
||||
* whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your
|
||||
* accounts" alert and the connectors "Reconnect" row — key off this `error`
|
||||
* field, not off the connected flag. A fully deleted entry has no error and is
|
||||
* indistinguishable from "never connected", so no prompt would ever appear.
|
||||
*
|
||||
* Tokens with no recorded scopes (very old installs that never persisted them)
|
||||
* are also treated as stale. Safe to call on every startup — it's a no-op once
|
||||
* the grant covers all current scopes, and once invalidated the early return on
|
||||
* the missing token keeps it from re-running until the user reconnects.
|
||||
*/
|
||||
export async function disconnectGoogleIfScopesStale(): Promise<void> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const connection = await oauthRepo.read('google');
|
||||
|
||||
// Not connected (or already invalidated) — nothing to migrate.
|
||||
if (!connection.tokens) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerConfig = await getProviderConfig('google');
|
||||
const requiredScopes = providerConfig.scopes ?? [];
|
||||
if (requiredScopes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const granted = new Set(connection.tokens.scopes ?? []);
|
||||
const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
|
||||
if (missingScopes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
|
||||
'invalidating it so the user is prompted to reconnect with the new scopes.'
|
||||
);
|
||||
|
||||
// Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
|
||||
if (connection.mode === 'rowboat' && connection.tokens.access_token) {
|
||||
try {
|
||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) {
|
||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the stale token but keep the entry with an error so the reconnect
|
||||
// prompt fires (see the note above).
|
||||
await oauthRepo.upsert('google', {
|
||||
tokens: null,
|
||||
error: 'Google permissions changed. Please reconnect to continue.',
|
||||
});
|
||||
|
||||
// Nudge any already-open window to re-read state. The renderer's initial
|
||||
// mount also re-reads, so the prompt shows even if no window is up yet.
|
||||
emitOAuthEvent({ provider: 'google', success: false });
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Google scope migration check failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a provider (internal use only)
|
||||
* Refreshes token if expired
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eigenpal/docx-editor-react": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
|
@ -47,15 +46,6 @@
|
|||
"motion": "^12.23.26",
|
||||
"nanoid": "^5.1.6",
|
||||
"posthog-js": "^1.332.0",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.7",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.41.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
|
|
|||
|
|
@ -35,30 +35,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Radix Collapsible expand/collapse — animate height (via the radix CSS var)
|
||||
plus a subtle fade. Used by the web search card. */
|
||||
@keyframes collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
|
|
@ -256,10 +232,6 @@
|
|||
color: var(--gm-text-faint);
|
||||
}
|
||||
|
||||
.gmail-row-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gmail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
|
||||
|
|
@ -277,56 +249,20 @@
|
|||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-row-actions {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover .gmail-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover .gmail-row-date {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.gmail-row-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-row-action:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text-strong);
|
||||
}
|
||||
|
||||
.gmail-row-action-danger:hover {
|
||||
color: #e8453c;
|
||||
}
|
||||
|
||||
.gmail-row:hover {
|
||||
background: var(--gm-bg-row-hover);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.upcoming-event-row {
|
||||
background-color: transparent;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.upcoming-event-row:hover {
|
||||
background-color: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-row-selected {
|
||||
background: var(--gm-bg-row-selected);
|
||||
box-shadow: inset 2px 0 0 var(--gm-accent);
|
||||
|
|
@ -560,7 +496,6 @@
|
|||
|
||||
.gmail-message-from span,
|
||||
.gmail-message-to,
|
||||
.gmail-message-cc,
|
||||
.gmail-message-date {
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 12px;
|
||||
|
|
@ -759,126 +694,6 @@
|
|||
font: inherit;
|
||||
}
|
||||
|
||||
.gmail-compose-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-compose-subject-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Recipient (To / Cc / Bcc) rows with editable chips */
|
||||
.gmail-recipient-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
padding-top: 5px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-recipient-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-recipient-input {
|
||||
flex: 1 1 80px;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-trailing {
|
||||
flex: none;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button:hover {
|
||||
color: var(--gm-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1200,10 +1015,6 @@
|
|||
--scrollbar-track: oklch(0.95 0 0);
|
||||
--scrollbar-thumb: oklch(0.75 0 0);
|
||||
--scrollbar-thumb-hover: oklch(0.65 0 0);
|
||||
/* Subtle raised-card surface: tints toward foreground, so it reads a hair
|
||||
darker than the background in light mode and a hair lighter in dark mode.
|
||||
Shared by the web search card and tool-call group. */
|
||||
--card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground));
|
||||
--rowboat-panel: oklch(0.97 0 0);
|
||||
--rowboat-raised: oklch(1 0 0);
|
||||
--rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,6 @@ import { useState, useRef, useEffect } from "react";
|
|||
|
||||
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||
query: string;
|
||||
options?: string[];
|
||||
onResponse: (response: string) => void;
|
||||
isProcessing?: boolean;
|
||||
};
|
||||
|
|
@ -17,21 +16,17 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
|
|||
export const AskHumanRequest = ({
|
||||
className,
|
||||
query,
|
||||
options,
|
||||
onResponse,
|
||||
isProcessing = false,
|
||||
...props
|
||||
}: AskHumanRequestProps) => {
|
||||
const [response, setResponse] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const hasOptions = Array.isArray(options) && options.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
|
||||
if (!hasOptions) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [hasOptions]);
|
||||
// Auto-focus the textarea when component mounts
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = response.trim();
|
||||
|
|
@ -41,11 +36,6 @@ export const AskHumanRequest = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleOptionClick = (option: string) => {
|
||||
if (isProcessing) return;
|
||||
onResponse(option);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -75,47 +65,30 @@ export const AskHumanRequest = ({
|
|||
{query}
|
||||
</p>
|
||||
</div>
|
||||
{hasOptions ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options!.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOptionClick(option)}
|
||||
disabled={isProcessing}
|
||||
className="bg-background"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
<div 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 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>
|
||||
|
|
|
|||
|
|
@ -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,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -8,10 +9,9 @@ import {
|
|||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||
|
|
@ -22,15 +22,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
|
|||
onDeny?: () => void;
|
||||
isProcessing?: boolean;
|
||||
response?: 'approve' | 'deny' | null;
|
||||
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||
};
|
||||
|
||||
const fileActionLabels: Record<string, string> = {
|
||||
read: "Read file",
|
||||
list: "List folder",
|
||||
search: "Search files",
|
||||
write: "Write files",
|
||||
delete: "Delete path",
|
||||
};
|
||||
|
||||
export const PermissionRequest = ({
|
||||
|
|
@ -42,33 +33,26 @@ export const PermissionRequest = ({
|
|||
onDeny,
|
||||
isProcessing = false,
|
||||
response = null,
|
||||
permission,
|
||||
...props
|
||||
}: PermissionRequestProps) => {
|
||||
// Extract command from arguments if it's executeCommand
|
||||
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
||||
const command = toolCall.toolName === "executeCommand"
|
||||
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||
? String(toolCall.arguments.command)
|
||||
: JSON.stringify(toolCall.arguments))
|
||||
: null;
|
||||
const filePermission = permission?.kind === "file" ? permission : null;
|
||||
|
||||
const isResponded = response !== null;
|
||||
const isApproved = response === 'approve';
|
||||
|
||||
// Once a response is chosen, collapse the details to just the header.
|
||||
// Users can click the header to expand them again.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showDetails = !isResponded || expanded;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose mb-4 w-full rounded-md border",
|
||||
isResponded
|
||||
? isApproved
|
||||
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
|
||||
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
|
||||
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
|
||||
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
|
||||
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
className
|
||||
)}
|
||||
|
|
@ -76,14 +60,17 @@ export const PermissionRequest = ({
|
|||
>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{!isResponded && (
|
||||
{isResponded ? (
|
||||
isApproved ? (
|
||||
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
|
||||
)
|
||||
) : (
|
||||
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
|
||||
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-sm text-foreground">
|
||||
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
||||
|
|
@ -93,15 +80,30 @@ export const PermissionRequest = ({
|
|||
</p>
|
||||
</div>
|
||||
{isResponded && (
|
||||
<ChevronDownIcon
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded ? "rotate-180" : "rotate-0"
|
||||
"shrink-0",
|
||||
isApproved
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{isApproved ? (
|
||||
<>
|
||||
<CheckIcon className="size-3 mr-1" />
|
||||
Approved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon className="size-3 mr-1" />
|
||||
Denied
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{showDetails && command && (
|
||||
{command && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Command
|
||||
|
|
@ -111,35 +113,7 @@ export const PermissionRequest = ({
|
|||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && filePermission && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Action
|
||||
</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Path{filePermission.paths.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
||||
{filePermission.paths.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Approval Scope
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
|
||||
{filePermission.pathPrefix}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && !command && !filePermission && toolCall.arguments && (
|
||||
{!command && toolCall.arguments && (
|
||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Arguments
|
||||
|
|
@ -159,12 +133,12 @@ export const PermissionRequest = ({
|
|||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={isProcessing}
|
||||
className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
|
||||
className={cn("flex-1", command && "rounded-r-none")}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
Approve
|
||||
</Button>
|
||||
{(command || filePermission) && (
|
||||
{command && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,28 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleCheck,
|
||||
LoaderIcon,
|
||||
ShieldCheckIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -51,68 +48,51 @@ const ToolCode = ({
|
|||
</pre>
|
||||
);
|
||||
|
||||
export type ToolAutoPermissionDetail = {
|
||||
decision: "allow";
|
||||
reason: string;
|
||||
};
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible> & {
|
||||
autoPermissionDetail?: ToolAutoPermissionDetail;
|
||||
};
|
||||
|
||||
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
|
||||
const toolCard = (
|
||||
<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 const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
/** Hide the leading status icon (used for child rows inside a tool group). */
|
||||
hideLeadIcon?: boolean;
|
||||
};
|
||||
|
||||
// Lead icon shown to the left of the tool label: spinner while running, a
|
||||
// green check when done, a red cross on error. Shared by ToolHeader (single
|
||||
// tools) and the tool-call group.
|
||||
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => {
|
||||
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />;
|
||||
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
|
||||
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
|
|
@ -120,7 +100,6 @@ export const ToolHeader = ({
|
|||
title,
|
||||
type,
|
||||
state,
|
||||
hideLeadIcon,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
||||
|
|
@ -128,13 +107,13 @@ export const ToolHeader = ({
|
|||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{!hideLeadIcon && getLeadIcon(state)}
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||
title={displayTitle}
|
||||
|
|
@ -142,7 +121,10 @@ export const ToolHeader = ({
|
|||
{displayTitle}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
};
|
||||
|
|
@ -152,7 +134,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
|||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -265,48 +247,41 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
|||
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}`
|
||||
const summary = isCompleted
|
||||
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
|
||||
: 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"
|
||||
className="not-prose mb-4 w-full rounded-md border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{leadIcon}
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={summaryText}
|
||||
key={summary}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
||||
title={summaryText}
|
||||
title={summary}
|
||||
>
|
||||
{summaryNode}
|
||||
{summary}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
||||
<CollapsibleContent className="border-t">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{group.items.map((tool) => {
|
||||
const toolState = toToolState(tool.status)
|
||||
|
|
@ -316,14 +291,12 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: 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"
|
||||
className="mb-0 border-border/60"
|
||||
>
|
||||
<ToolHeader
|
||||
title={getToolDisplayName(tool)}
|
||||
type={`tool-${tool.name}`}
|
||||
state={toolState}
|
||||
className="text-muted-foreground"
|
||||
hideLeadIcon
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
interface WebSearchResultProps {
|
||||
query: string;
|
||||
|
|
@ -21,219 +19,39 @@ interface WebSearchResultProps {
|
|||
title?: string;
|
||||
}
|
||||
|
||||
// How long each fetched website stays on the rolling header before the
|
||||
// next one slides in. Kept slow enough to read the domain + title.
|
||||
const ROLL_INTERVAL_MS = 700;
|
||||
|
||||
// How many favicons to show in the settled stack before the rest collapse
|
||||
// into a "+N" chip. The text names this many domains too, so the chip count
|
||||
// (total - MAX_STACK) lines up with the "and N others" in the summary.
|
||||
const MAX_STACK = 3;
|
||||
|
||||
function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function faviconUrl(domain: string, size = 32): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
|
||||
}
|
||||
|
||||
// Collapse the result list into unique domains, preserving order.
|
||||
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const result of results) {
|
||||
const domain = getDomain(result.url);
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
out.push(domain);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Summary with text hierarchy: "Searched" + "and N others" are secondary
|
||||
// weight/color, the domain names are primary text at medium weight.
|
||||
function buildSearchedSummary(domains: string[]): React.ReactNode {
|
||||
const muted = "font-normal text-muted-foreground";
|
||||
const name = (d: string) => <span className="font-medium text-foreground">{d}</span>;
|
||||
if (domains.length === 1) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (domains.length === 2) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}> and </span>
|
||||
{name(domains[1])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const others = domains.length - 2;
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}>, </span>
|
||||
{name(domains[1])}
|
||||
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type RollPhase = "searching" | "rolling" | "settled";
|
||||
|
||||
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
||||
const isRunning = status === "pending" || status === "running";
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const domains = useMemo(() => uniqueDomains(results), [results]);
|
||||
|
||||
// Drive the one-shot rolling reveal. Results arrive all at once, so we
|
||||
// simulate "fetching one site at a time" by stepping through them with the
|
||||
// same slide animation the tool group uses, then settle on a summary.
|
||||
// `settled` is seeded from the initial status so a card loaded already-
|
||||
// complete from history skips straight to the summary (no roll).
|
||||
const [settled, setSettled] = useState(() => !isRunning);
|
||||
const [rollIndex, setRollIndex] = useState(0);
|
||||
|
||||
// Phase is fully derived: searching while the tool runs, rolling once
|
||||
// results land, then settled. No setState-in-effect needed for transitions.
|
||||
const phase: RollPhase = isRunning
|
||||
? "searching"
|
||||
: !settled && results.length > 0
|
||||
? "rolling"
|
||||
: "settled";
|
||||
|
||||
// Warm the browser cache for every favicon the moment results arrive, so
|
||||
// each icon is already loaded by the time its row rolls in (~700ms each).
|
||||
// Without this the network fetch lags the text and rows flash icon-less.
|
||||
useEffect(() => {
|
||||
for (const result of results) {
|
||||
const img = new Image();
|
||||
img.src = faviconUrl(getDomain(result.url));
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
// Advance the roll, then settle after the last site has had its moment.
|
||||
// setState only fires inside the timeout callback, never synchronously.
|
||||
useEffect(() => {
|
||||
if (phase !== "rolling") return;
|
||||
const isLast = rollIndex >= results.length - 1;
|
||||
const timer = setTimeout(
|
||||
() => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)),
|
||||
ROLL_INTERVAL_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [phase, rollIndex, results.length]);
|
||||
|
||||
// Build the content for the compact (collapsed) header line. Each distinct
|
||||
// value gets a unique key so AnimatePresence runs the slide transition.
|
||||
let headerKey: string;
|
||||
let headerContent: React.ReactNode;
|
||||
if (phase === "searching") {
|
||||
headerKey = "searching";
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin" />
|
||||
<span className="truncate">Searching the web…</span>
|
||||
</span>
|
||||
);
|
||||
} else if (phase === "rolling") {
|
||||
const result = results[rollIndex];
|
||||
const domain = getDomain(result.url);
|
||||
headerKey = `roll-${rollIndex}`;
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<img src={faviconUrl(domain)} alt="" className="size-4 shrink-0 rounded-sm bg-muted/60" />
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
<span className="text-muted-foreground/50"> · </span>
|
||||
<span>{result.title}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
headerKey = "settled";
|
||||
const stack = domains.slice(0, MAX_STACK);
|
||||
// Chip count matches the "and N others" in the text (total minus the 2
|
||||
// named domains), shown only when there are sites beyond the stack.
|
||||
const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0;
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{domains.length > 0 ? (
|
||||
<span className="flex shrink-0 items-center">
|
||||
{stack.map((domain, i) => (
|
||||
<img
|
||||
key={domain}
|
||||
src={faviconUrl(domain)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0"
|
||||
style={{ zIndex: stack.length - i }}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{domains.length > 0 ? buildSearchedSummary(domains) : title}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
||||
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={headerKey}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex items-center text-left font-medium text-sm"
|
||||
>
|
||||
{headerContent}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{phase === "settled" && domains.length > 0 && (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{domains.length} source{domains.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
{/* Query */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{query}</span>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Query + result count */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{query}</span>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
|
|
@ -255,7 +73,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={faviconUrl(domain)}
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||
alt=""
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
|
|
@ -270,13 +88,20 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — only while the search is still running. */}
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||
<span>Done</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
|
|||
|
|
@ -1237,8 +1237,6 @@ function TaskDetail({
|
|||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [outputRefreshKey, setOutputRefreshKey] = useState(0)
|
||||
// Whether we've already chosen the initial sidebar state for this task.
|
||||
const sidebarInitialized = useRef(false)
|
||||
|
||||
const agentStatus = useBackgroundTaskAgentStatus()
|
||||
const liveStatus = agentStatus.get(slug)
|
||||
|
|
@ -1254,23 +1252,6 @@ function TaskDetail({
|
|||
if (result.success && result.task) {
|
||||
setTask(result.task)
|
||||
setDraft(result.task)
|
||||
// On first open, collapse the details sidebar when the agent
|
||||
// already has output — let the user read it without chrome.
|
||||
// Resolved before `loading` clears so the sidebar never flashes.
|
||||
if (!sidebarInitialized.current) {
|
||||
sidebarInitialized.current = true
|
||||
try {
|
||||
const out = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.md`,
|
||||
})
|
||||
const body = (out.data ?? '').trim()
|
||||
if (body && body !== `# ${result.task.name}`) {
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
} catch {
|
||||
// No output file yet — keep the sidebar open.
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -1437,18 +1418,6 @@ export interface BgTasksViewProps {
|
|||
* "Edit with Copilot" button in the detail-view sidebar footer.
|
||||
*/
|
||||
onEditWithCopilot?: (slug: string) => void
|
||||
/**
|
||||
* If provided, the view opens with this task already selected. Updates to
|
||||
* this prop sync into internal state so the sidebar can swap which task is
|
||||
* focused without remounting the view.
|
||||
*/
|
||||
initialSlug?: string | null
|
||||
/**
|
||||
* Bump this counter to force a re-focus on `initialSlug` even when the
|
||||
* slug value itself didn't change (e.g. user clicks the same task in the
|
||||
* sidebar twice after navigating away inside the view).
|
||||
*/
|
||||
slugVersion?: number
|
||||
}
|
||||
|
||||
function formatLastRanLabel(iso: string | null | undefined): string {
|
||||
|
|
@ -1456,12 +1425,9 @@ function formatLastRanLabel(iso: string | null | undefined): string {
|
|||
return formatRelativeTime(iso) || 'Never'
|
||||
}
|
||||
|
||||
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlug, slugVersion }: BgTasksViewProps = {}) {
|
||||
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot }: BgTasksViewProps = {}) {
|
||||
const [items, setItems] = useState<BackgroundTaskSummary[]>([])
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(initialSlug ?? null)
|
||||
useEffect(() => {
|
||||
setSelectedSlug(initialSlug ?? null)
|
||||
}, [initialSlug, slugVersion])
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showNewDialog, setShowNewDialog] = useState(false)
|
||||
|
|
|
|||
|
|
@ -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,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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
|
|
@ -10,34 +10,25 @@ import {
|
|||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FolderCheck,
|
||||
FolderClock,
|
||||
FolderCog,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Headphones,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Terminal,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -69,12 +60,6 @@ export type StagedAttachment = {
|
|||
}
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
|
||||
const MAX_STORED_RECENT_WORK_DIRS = 8
|
||||
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
|
||||
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
|
||||
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
||||
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
||||
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
|
|
@ -95,18 +80,11 @@ interface ConfiguredModel {
|
|||
model: string
|
||||
}
|
||||
|
||||
type RecentWorkDir = {
|
||||
path: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type PermissionMode = 'manual' | 'auto'
|
||||
|
||||
function getSelectedModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
|
@ -130,86 +108,8 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
|
||||
if (typeof value === 'string') {
|
||||
const path = value.trim()
|
||||
return path ? { path, lastUsedAt: 0 } : null
|
||||
}
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const entry = value as Record<string, unknown>
|
||||
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
|
||||
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
|
||||
? entry.lastUsedAt
|
||||
: 0
|
||||
return path ? { path, lastUsedAt } : null
|
||||
}
|
||||
|
||||
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
|
||||
const parsed = JSON.parse(result.data)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const seen = new Set<string>()
|
||||
const dirs: RecentWorkDir[] = []
|
||||
for (const value of parsed) {
|
||||
const entry = normalizeRecentWorkDir(value)
|
||||
if (!entry || seen.has(entry.path)) continue
|
||||
seen.add(entry.path)
|
||||
dirs.push(entry)
|
||||
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
|
||||
}
|
||||
return dirs
|
||||
} catch {
|
||||
// File missing or invalid — no recents yet.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: RECENT_WORK_DIRS_CONFIG_PATH,
|
||||
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to persist recent work directories', err)
|
||||
}
|
||||
// Notify other mounted chat inputs in this window to re-read.
|
||||
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
function formatRecentWorkDirTime(lastUsedAt: number) {
|
||||
if (!lastUsedAt) return ''
|
||||
const now = Date.now()
|
||||
const diffMs = Math.max(0, now - lastUsedAt)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diffMs < minute) return 'now'
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
|
||||
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
|
||||
|
||||
const used = new Date(lastUsedAt)
|
||||
const yesterday = new Date(now - day)
|
||||
if (
|
||||
used.getFullYear() === yesterday.getFullYear() &&
|
||||
used.getMonth() === yesterday.getMonth() &&
|
||||
used.getDate() === yesterday.getDate()
|
||||
) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
if (diffMs < 7 * day) {
|
||||
return used.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
}
|
||||
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function compactWorkDirPath(path: string) {
|
||||
return path.replace(/^\/Users\/[^/]+/, '~')
|
||||
}
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
@ -233,10 +133,6 @@ interface ChatInputInnerProps {
|
|||
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({
|
||||
|
|
@ -263,8 +159,6 @@ function ChatInputInner({
|
|||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
workDir = null,
|
||||
onWorkDirChange,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -279,62 +173,12 @@ function ChatInputInner({
|
|||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
||||
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
|
||||
|
||||
// Responsive toolbar: measure real overflow and progressively collapse items
|
||||
// right→left until everything fits. Stages:
|
||||
// 1 code→icon · 2 perm→icon · 3 search label hidden · 4 workDir→icon
|
||||
// 5 code→menu · 6 perm→menu · 7 search→menu · 8 workDir→menu
|
||||
// Once items move into the "⋯" overflow menu (≥5) no icon is ever hidden.
|
||||
// overflow-hidden on the left group is the hard guarantee against any overlap.
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const leftGroupRef = useRef<HTMLDivElement>(null)
|
||||
const lastWidthRef = useRef(0)
|
||||
const [collapseLevel, setCollapseLevel] = useState(0)
|
||||
|
||||
// Re-evaluate from scratch (level 0) whenever the available width changes…
|
||||
useEffect(() => {
|
||||
const outer = toolbarRef.current
|
||||
if (!outer) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = outer.clientWidth
|
||||
if (w !== lastWidthRef.current) {
|
||||
lastWidthRef.current = w
|
||||
setCollapseLevel(0)
|
||||
}
|
||||
})
|
||||
ro.observe(outer)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// …or when the *set* of items changes (an item appears/disappears, or the model
|
||||
// name width changes). Deliberately excludes the in-place toggles (searchEnabled,
|
||||
// permissionMode, codeModeEnabled, codingAgent): those fire from the overflow menu
|
||||
// for items already inside it, so resetting here would unmount the open menu. The
|
||||
// no-dep effect below still re-collapses if any toggle happens to widen the row.
|
||||
useLayoutEffect(() => {
|
||||
setCollapseLevel(0)
|
||||
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey])
|
||||
|
||||
// After each render, if the left group still overflows, collapse one more step.
|
||||
// Runs before paint, so the intermediate (overflowing) state is never visible.
|
||||
useLayoutEffect(() => {
|
||||
const el = leftGroupRef.current
|
||||
if (!el) return
|
||||
if (el.scrollWidth > el.clientWidth + 1 && collapseLevel < 8) {
|
||||
setCollapseLevel((l) => Math.min(8, l + 1))
|
||||
}
|
||||
})
|
||||
const [workDir, setWorkDir] = useState<string | null>(null)
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setLockedModel(null)
|
||||
setPermissionMode('auto')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
|
@ -343,20 +187,10 @@ function ChatInputInner({
|
|||
if (run.provider && run.model) {
|
||||
setLockedModel({ provider: run.provider, model: run.model })
|
||||
}
|
||||
setPermissionMode(run.permissionMode ?? 'manual')
|
||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
|
||||
syncRecentWorkDirs()
|
||||
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||
return () => {
|
||||
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
|
|
@ -422,146 +256,54 @@ function ChatInputInner({
|
|||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Load the global code-mode feature flag (from settings) and stay in sync.
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
window.ipc.invoke('codeMode:getConfig', null)
|
||||
.then((r) => setCodeModeFeatureEnabled(r.enabled))
|
||||
.catch(() => setCodeModeFeatureEnabled(false))
|
||||
}
|
||||
load()
|
||||
window.addEventListener('code-mode-config-changed', load)
|
||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||
}, [])
|
||||
|
||||
// If the feature is turned off in settings, also turn off any per-conversation chip.
|
||||
useEffect(() => {
|
||||
if (!codeModeFeatureEnabled && codeModeEnabled) {
|
||||
setCodeModeEnabled(false)
|
||||
}
|
||||
}, [codeModeFeatureEnabled, codeModeEnabled])
|
||||
|
||||
|
||||
// Cross-platform basename — handles both / and \ separators.
|
||||
const basename = useCallback((p: string): string => {
|
||||
const trimmed = p.replace(/[\\/]+$/, '')
|
||||
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
||||
}, [])
|
||||
|
||||
const rememberWorkDir = useCallback(async (dir: string) => {
|
||||
const trimmed = dir.trim()
|
||||
if (!trimmed) return
|
||||
const next = [
|
||||
{ path: trimmed, lastUsedAt: Date.now() },
|
||||
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
|
||||
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
|
||||
setRecentWorkDirs(next)
|
||||
await writeRecentWorkDirs(next)
|
||||
}, [])
|
||||
|
||||
// Load coding-agent preference for a given workdir.
|
||||
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
|
||||
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
|
||||
if (!dir) return 'claude'
|
||||
// Load currently configured work directory
|
||||
const loadWorkDir = useCallback(async () => {
|
||||
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
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
|
||||
setWorkDir(value || null)
|
||||
} catch {
|
||||
/* file missing or invalid — fall through to default */
|
||||
setWorkDir(null)
|
||||
}
|
||||
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])
|
||||
loadWorkDir()
|
||||
}, [isActive, loadWorkDir])
|
||||
|
||||
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,
|
||||
defaultPath: workDir ?? undefined,
|
||||
})
|
||||
if (!chosen) return
|
||||
onWorkDirChange?.(chosen)
|
||||
await rememberWorkDir(chosen)
|
||||
setCodingAgent(await loadCodingAgentFor(chosen))
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: 'config/workdir.json',
|
||||
data: JSON.stringify({ path: chosen }, null, 2),
|
||||
})
|
||||
setWorkDir(chosen)
|
||||
toast.success(`Work directory set: ${chosen}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to set work directory', err)
|
||||
toast.error('Failed to set work directory')
|
||||
}
|
||||
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
}, [workDir])
|
||||
|
||||
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
|
||||
const handleClearWorkDir = useCallback(async () => {
|
||||
try {
|
||||
await persistCodingAgent(workDir, next)
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: 'config/workdir.json',
|
||||
data: JSON.stringify({}, null, 2),
|
||||
})
|
||||
setWorkDir(null)
|
||||
toast.success('Work directory cleared')
|
||||
} catch (err) {
|
||||
console.error('Failed to save coding agent', err)
|
||||
toast.error('Failed to save coding agent')
|
||||
// revert on failure
|
||||
setCodingAgent(codingAgent)
|
||||
console.error('Failed to clear work directory', err)
|
||||
toast.error('Failed to clear work directory')
|
||||
}
|
||||
}, [workDir, codingAgent, persistCodingAgent])
|
||||
}, [])
|
||||
|
||||
// Check search tool availability (exa or signed-in via gateway)
|
||||
useEffect(() => {
|
||||
|
|
@ -647,15 +389,12 @@ function ChatInputInner({
|
|||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit) return
|
||||
// codeMode is sticky per conversation — don't reset after send.
|
||||
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
|
||||
controller.textInput.clear()
|
||||
controller.mentions.clearMentions()
|
||||
setAttachments([])
|
||||
// Web search toggle stays on for the rest of the chat session; the user
|
||||
// turns it off explicitly. (Not persisted across app restarts.)
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
|
||||
setSearchEnabled(false)
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
|
|
@ -694,12 +433,6 @@ function ChatInputInner({
|
|||
}
|
||||
}, [addFiles, isActive])
|
||||
|
||||
const visibleRecentWorkDirs = recentWorkDirs
|
||||
.filter((entry) => entry.path !== workDir)
|
||||
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
|
||||
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
|
||||
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
||||
|
||||
return (
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
|
|
@ -804,344 +537,95 @@ function ChatInputInner({
|
|||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div ref={toolbarRef} className="flex items-center gap-2 px-4 pb-3">
|
||||
<div ref={leftGroupRef} className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
|
||||
<ImagePlus className="size-4" />
|
||||
<span>Add files or photos</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
|
||||
<FolderCog className="size-4" />
|
||||
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
||||
</DropdownMenuItem>
|
||||
{workDir && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}>
|
||||
<X className="size-4" />
|
||||
<span>Clear work directory</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{workDir && collapseLevel < 8 && (
|
||||
{workDir && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Level 4: collapse to a square icon */}
|
||||
<div className={cn(
|
||||
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
|
||||
)}>
|
||||
<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" />
|
||||
{collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
|
||||
</button>
|
||||
{collapseLevel < 4 && (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex h-7 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Work directory: {workDir}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{searchAvailable && collapseLevel < 7 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled((v) => !v)}
|
||||
aria-label="Search"
|
||||
aria-pressed={searchEnabled}
|
||||
className={cn(
|
||||
'flex h-7 shrink-0 items-center rounded-full border px-1.5 transition-colors duration-150 ease-out',
|
||||
searchEnabled
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
{searchEnabled && collapseLevel < 3 && (
|
||||
<span className="ml-1.5 whitespace-nowrap text-xs font-medium">
|
||||
Search
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{collapseLevel < 6 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{searchAvailable && (
|
||||
searchEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (runId) return
|
||||
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
|
||||
}}
|
||||
disabled={Boolean(runId)}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
collapseLevel >= 2 ? "w-7 justify-center" : "px-2.5",
|
||||
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"
|
||||
onClick={() => setSearchEnabled(false)}
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
{collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>}
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Search</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{runId
|
||||
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
|
||||
: permissionMode === 'auto'
|
||||
? 'Auto-permission on — click for manual approval prompts'
|
||||
: 'Manual approval prompts — click for auto-permission'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? (
|
||||
collapseLevel >= 1 ? (
|
||||
/* Level 1: collapse the pill to a single icon */
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCodeModeEnabled(false)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled(true)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
{collapseLevel >= 5 && (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More options"
|
||||
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"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">More options</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" side="top" className="min-w-52">
|
||||
{workDir && collapseLevel >= 8 && (
|
||||
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
|
||||
<FolderCog className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{basename(workDir) || workDir}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{searchAvailable && collapseLevel >= 7 && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={searchEnabled}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setSearchEnabled(Boolean(c))}
|
||||
>
|
||||
Web search
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
{collapseLevel >= 6 && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={permissionMode === 'auto'}
|
||||
disabled={Boolean(runId)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')}
|
||||
>
|
||||
Auto-approve actions
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
{codeModeFeatureEnabled && collapseLevel >= 5 && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={codeModeEnabled}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
|
||||
>
|
||||
Code mode
|
||||
</DropdownMenuCheckboxItem>
|
||||
{codeModeEnabled && (
|
||||
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
|
||||
<Terminal className="size-4" />
|
||||
<span className="min-w-0 flex-1">Coding agent</span>
|
||||
<span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{lockedModel ? (
|
||||
<span
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||
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="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
) : configuredModels.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 min-w-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="min-w-0 truncate">
|
||||
<span className="max-w-[150px] truncate">
|
||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
@ -1283,7 +767,7 @@ export interface ChatInputWithMentionsProps {
|
|||
knowledgeFiles: string[]
|
||||
recentFiles: string[]
|
||||
visibleFiles: string[]
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
@ -1306,8 +790,6 @@ export interface ChatInputWithMentionsProps {
|
|||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
workDir?: string | null
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -1337,8 +819,6 @@ export function ChatInputWithMentions({
|
|||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -1366,8 +846,6 @@ export function ChatInputWithMentions({
|
|||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
workDir={workDir}
|
||||
onWorkDirChange={onWorkDirChange}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
|
||||
import { Bug, Maximize2, Minimize2, MoreHorizontal, SquarePen } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChatHeader } from '@/components/chat-header'
|
||||
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -16,6 +14,7 @@ import {
|
|||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import {
|
||||
|
|
@ -28,20 +27,19 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent }
|
|||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
|
||||
import { TerminalOutput } from '@/components/terminal-output'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import type { ChatPaneSize } from '@/contexts/theme-context'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
|
|
@ -61,7 +59,6 @@ import {
|
|||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { matchBillingError } from '@/lib/billing-error'
|
||||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
|
|
@ -95,6 +92,60 @@ function AutoScrollPre({ className, children }: { className?: string; children:
|
|||
)
|
||||
}
|
||||
|
||||
/* ─── Billing error helpers ─── */
|
||||
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: 'You\'ve run out of credits',
|
||||
subtitle: 'Upgrade your plan for more credits. Free usage resets daily 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
|
||||
|
||||
function matchBillingError(message: string) {
|
||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||
}
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
appUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
function BillingErrorCTA({ label }: { label: string }) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!appUrl) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 360
|
||||
const MAX_WIDTH = 1600
|
||||
const MIN_MAIN_PANE_WIDTH = 420
|
||||
|
|
@ -126,16 +177,13 @@ interface ChatSidebarProps {
|
|||
defaultWidth?: number
|
||||
isOpen?: boolean
|
||||
isMaximized?: boolean
|
||||
placement?: 'middle' | 'right'
|
||||
paneSize?: ChatPaneSize
|
||||
className?: string
|
||||
chatTabs: ChatTab[]
|
||||
activeChatTabId: string
|
||||
getChatTabTitle: (tab: ChatTab) => string
|
||||
isChatTabProcessing: (tab: ChatTab) => boolean
|
||||
onSwitchChatTab: (tabId: string) => void
|
||||
onCloseChatTab: (tabId: string) => void
|
||||
onNewChatTab: () => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||
onSelectRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
onOpenFullScreen?: () => void
|
||||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
|
|
@ -144,7 +192,7 @@ interface ChatSidebarProps {
|
|||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
knowledgeFiles?: string[]
|
||||
recentFiles?: string[]
|
||||
visibleFiles?: string[]
|
||||
|
|
@ -154,12 +202,9 @@ interface ChatSidebarProps {
|
|||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
workDirByTab?: Record<string, string | null>
|
||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||
|
|
@ -187,16 +232,13 @@ export function ChatSidebar({
|
|||
defaultWidth = DEFAULT_WIDTH,
|
||||
isOpen = true,
|
||||
isMaximized = false,
|
||||
placement = 'right',
|
||||
paneSize = 'chat-smaller',
|
||||
className,
|
||||
chatTabs,
|
||||
activeChatTabId,
|
||||
getChatTabTitle,
|
||||
isChatTabProcessing,
|
||||
onSwitchChatTab,
|
||||
onCloseChatTab,
|
||||
onNewChatTab,
|
||||
recentRuns = [],
|
||||
onSelectRun,
|
||||
onOpenChatHistory,
|
||||
onOpenFullScreen,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
|
|
@ -215,12 +257,9 @@ export function ChatSidebar({
|
|||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
workDirByTab = {},
|
||||
onWorkDirChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
autoPermissionDecisions = new Map(),
|
||||
onPermissionResponse,
|
||||
onAskHumanResponse,
|
||||
isToolOpenForTab,
|
||||
|
|
@ -253,8 +292,6 @@ export function ChatSidebar({
|
|||
const startWidthRef = useRef(0)
|
||||
const prevIsMaximizedRef = useRef(isMaximized)
|
||||
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
||||
const isMiddlePlacement = placement === 'middle'
|
||||
const isResizable = paneSize === 'chat-smaller'
|
||||
|
||||
const getMaxAllowedWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') return MAX_WIDTH
|
||||
|
|
@ -315,9 +352,7 @@ export function ChatSidebar({
|
|||
setIsResizing(true)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const delta = isMiddlePlacement
|
||||
? event.clientX - startXRef.current
|
||||
: startXRef.current - event.clientX
|
||||
const delta = startXRef.current - event.clientX
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||
}
|
||||
|
|
@ -330,7 +365,7 @@ export function ChatSidebar({
|
|||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width, getMaxAllowedWidth, isMiddlePlacement])
|
||||
}, [width, getMaxAllowedWidth])
|
||||
|
||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: runId ?? null,
|
||||
|
|
@ -339,7 +374,6 @@ export function ChatSidebar({
|
|||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
}), [
|
||||
runId,
|
||||
conversation,
|
||||
|
|
@ -347,13 +381,13 @@ export function ChatSidebar({
|
|||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
])
|
||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||
if (tabId === activeChatTabId) return activeTabState
|
||||
return chatTabStates[tabId] ?? emptyTabState
|
||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
||||
const activeRunId = activeTabState.runId
|
||||
const handleDownloadChatLog = useCallback(async () => {
|
||||
if (!activeRunId) {
|
||||
|
|
@ -374,11 +408,7 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [activeRunId])
|
||||
|
||||
const renderConversationItem = (
|
||||
item: ConversationItem,
|
||||
tabId: string,
|
||||
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||
) => {
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
|
|
@ -471,7 +501,6 @@ export function ChatSidebar({
|
|||
key={item.id}
|
||||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||
autoPermissionDetail={options?.autoPermissionDetail}
|
||||
>
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
|
|
@ -488,8 +517,19 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
if (isErrorMessage(item)) {
|
||||
if (matchBillingError(item.message)) {
|
||||
return null
|
||||
const billingError = matchBillingError(item.message)
|
||||
if (billingError) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
|
||||
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
|
||||
<BillingErrorCTA label={billingError.cta} />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
|
|
@ -512,11 +552,8 @@ export function ChatSidebar({
|
|||
// not add extra width to the right and overflow the app viewport.
|
||||
return { width: 0, flex: '1 1 auto' }
|
||||
}
|
||||
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
|
||||
return { width: 0, flex: '1 1 0' }
|
||||
}
|
||||
return { width, flex: '0 0 auto' }
|
||||
}, [isOpen, isMaximized, paneSize, width])
|
||||
}, [isOpen, isMaximized, width])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -525,19 +562,16 @@ export function ChatSidebar({
|
|||
onMouseDownCapture={onActivate}
|
||||
onFocusCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col overflow-hidden bg-background',
|
||||
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
|
||||
className
|
||||
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{!isMaximized && isResizable && (
|
||||
{!isMaximized && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
|
||||
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
|
||||
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||
'hover:after:bg-sidebar-border',
|
||||
isResizing && 'after:bg-primary'
|
||||
|
|
@ -555,17 +589,28 @@ export function ChatSidebar({
|
|||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
<TabBar
|
||||
tabs={chatTabs}
|
||||
activeTabId={activeChatTabId}
|
||||
getTabTitle={getChatTabTitle}
|
||||
getTabId={(tab) => tab.id}
|
||||
isProcessing={isChatTabProcessing}
|
||||
onSwitchTab={onSwitchChatTab}
|
||||
onCloseTab={onCloseChatTab}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -602,14 +647,14 @@ export function ChatSidebar({
|
|||
size="icon"
|
||||
onClick={onOpenFullScreen}
|
||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
|
||||
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
>
|
||||
{isMaximized
|
||||
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
|
||||
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
|
||||
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</header>
|
||||
|
|
@ -638,24 +683,16 @@ export function ChatSidebar({
|
|||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={cn(
|
||||
'mx-auto w-full max-w-4xl px-3',
|
||||
tabHasConversation ? 'pb-28' : 'pb-0',
|
||||
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
|
||||
)}>
|
||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
recentRuns={recentRuns}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
onPickPrompt={setLocalPresetMessage}
|
||||
/>
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{groupConversationItems(
|
||||
tabState.conversation,
|
||||
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||
(id) => !!tabState.allPermissionRequests.get(id)
|
||||
).map((item) => {
|
||||
if (isToolGroup(item)) {
|
||||
return (
|
||||
|
|
@ -667,44 +704,23 @@ export function ChatSidebar({
|
|||
/>
|
||||
)
|
||||
}
|
||||
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 rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{deniedAutoDecision && (
|
||||
<AutoPermissionDecision
|
||||
toolCall={deniedAutoDecision.toolCall}
|
||||
permission={deniedAutoDecision.permission}
|
||||
decision={deniedAutoDecision.decision}
|
||||
reason={deniedAutoDecision.reason}
|
||||
/>
|
||||
)}
|
||||
{permRequest && onPermissionResponse && (
|
||||
<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}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -749,6 +765,9 @@ export function ChatSidebar({
|
|||
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
||||
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="mx-auto w-full max-w-4xl px-3">
|
||||
{!hasConversation && (
|
||||
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
||||
)}
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getTabState(tab.id)
|
||||
|
|
@ -777,8 +796,6 @@ export function ChatSidebar({
|
|||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
workDir={workDirByTab[tab.id] ?? null}
|
||||
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
|
|
|
|||
|
|
@ -1,253 +0,0 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleDot,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader,
|
||||
Pencil,
|
||||
Search,
|
||||
ShieldQuestion,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
|
||||
// ── Timeline reduction ──────────────────────────────────────────────
|
||||
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
|
||||
// folding tool_call + tool_call_update (by id) and the latest plan in place.
|
||||
|
||||
type TextRow = { kind: 'text'; id: string; text: string }
|
||||
type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] }
|
||||
type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] }
|
||||
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
|
||||
type Row = TextRow | ToolRow | PlanRow | PermRow
|
||||
|
||||
function reduceEvents(events: CodeRunEvent[]): Row[] {
|
||||
const rows: Row[] = []
|
||||
const toolIdx = new Map<string, number>()
|
||||
let planIdx = -1
|
||||
|
||||
events.forEach((e, i) => {
|
||||
switch (e.type) {
|
||||
case 'message': {
|
||||
if (e.role !== 'agent' || !e.text) return
|
||||
const last = rows[rows.length - 1]
|
||||
if (last && last.kind === 'text') last.text += e.text
|
||||
else rows.push({ kind: 'text', id: `t${i}`, text: e.text })
|
||||
break
|
||||
}
|
||||
case 'tool_call': {
|
||||
const id = e.id ?? `tc${i}`
|
||||
const at = toolIdx.get(id)
|
||||
if (at != null) {
|
||||
const r = rows[at] as ToolRow
|
||||
r.title = e.title ?? r.title
|
||||
r.toolKind = e.kind ?? r.toolKind
|
||||
r.status = e.status ?? r.status
|
||||
} else {
|
||||
toolIdx.set(id, rows.length)
|
||||
rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_call_update': {
|
||||
const id = e.id ?? `tu${i}`
|
||||
let at = toolIdx.get(id)
|
||||
if (at == null) {
|
||||
at = rows.length
|
||||
toolIdx.set(id, at)
|
||||
rows.push({ kind: 'tool', id, diffs: [] })
|
||||
}
|
||||
const r = rows[at] as ToolRow
|
||||
if (e.status) r.status = e.status
|
||||
for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d)
|
||||
break
|
||||
}
|
||||
case 'plan': {
|
||||
if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries
|
||||
else {
|
||||
planIdx = rows.length
|
||||
rows.push({ kind: 'plan', id: 'plan', entries: e.entries })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'permission':
|
||||
rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
function toolKindIcon(kind?: string) {
|
||||
switch (kind) {
|
||||
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
function planMarker(status?: string) {
|
||||
if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
|
||||
if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" />
|
||||
return <Circle className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||
|
||||
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
||||
const rows = useMemo(() => reduceEvents(events), [events])
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent…</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
{rows.map((row) => {
|
||||
if (row.kind === 'text') {
|
||||
return (
|
||||
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||
{row.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'tool') {
|
||||
const running = row.status !== 'completed' && row.status !== 'failed'
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{running
|
||||
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
|
||||
{toolKindIcon(row.toolKind)}
|
||||
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
|
||||
</div>
|
||||
{row.diffs.length > 0 && (
|
||||
<div className="ml-7 flex flex-col gap-0.5">
|
||||
{row.diffs.map((d) => (
|
||||
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
|
||||
{basename(d)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'plan') {
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2">
|
||||
{row.entries.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm text-foreground/90">
|
||||
{planMarker(entry.status)}
|
||||
<span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}>
|
||||
{entry.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// resolved permission
|
||||
const denied = row.decision === 'reject' || row.decision === 'cancelled'
|
||||
return (
|
||||
<div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}>
|
||||
{denied ? '✕' : '✓'}
|
||||
<span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── In-run permission card ──────────────────────────────────────────
|
||||
|
||||
export function CodeRunPermissionRequest({
|
||||
ask,
|
||||
onDecide,
|
||||
}: {
|
||||
ask: PermissionAsk
|
||||
onDecide: (decision: PermissionDecision) => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const decide = (d: PermissionDecision) => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
onDecide(d)
|
||||
}
|
||||
const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50'
|
||||
return (
|
||||
<div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<ShieldQuestion className="size-4 shrink-0 text-amber-600" />
|
||||
Permission needed
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The agent wants to: <span className="font-medium text-foreground">{ask.title}</span>
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" disabled={busy} onClick={() => decide('allow_once')}
|
||||
className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}>
|
||||
Allow
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide('allow_always')}
|
||||
className={cn(btn, 'border hover:bg-muted')}>
|
||||
Always allow{ask.kind ? ` (${ask.kind})` : ''}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide('reject')}
|
||||
className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ──
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
|
||||
export function CodingRunBlock({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onPermissionDecision,
|
||||
}: {
|
||||
item: ToolCall
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPermissionDecision: (decision: PermissionDecision) => void
|
||||
}) {
|
||||
// Prefer the agent the backend actually ran (the chip) once the run returns; fall
|
||||
// back to the requested input agent while it's still in flight. Never trust only the
|
||||
// model's input — it can pass a stale agent the backend overrode with the chip.
|
||||
const agent =
|
||||
(item.result as { agent?: string } | undefined)?.agent ??
|
||||
(item.input as { agent?: string } | undefined)?.agent
|
||||
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
|
||||
return (
|
||||
<>
|
||||
<Tool open={open} onOpenChange={onOpenChange}>
|
||||
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
{item.pendingCodePermission && (
|
||||
<CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
|
||||
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react'
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -8,16 +8,9 @@ import type { blocks } from '@x/shared'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
|
||||
type GmailThread = blocks.GmailThread
|
||||
type GmailThreadMessage = blocks.GmailThreadMessage
|
||||
type GmailConnectionStatus = {
|
||||
connected: boolean
|
||||
hasRequiredScope: boolean
|
||||
missingScopes: string[]
|
||||
email: string | null
|
||||
}
|
||||
|
||||
function formatInboxTime(value?: string): string {
|
||||
if (!value) return ''
|
||||
|
|
@ -69,31 +62,6 @@ function snippet(text?: string): string {
|
|||
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
}
|
||||
|
||||
function isReplyQuoteBoundary(lines: string[], index: number): boolean {
|
||||
const line = lines[index]?.trim() || ''
|
||||
if (/^On\b.+\bwrote:\s*$/i.test(line)) return true
|
||||
if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true
|
||||
if (/^From:\s+\S/i.test(line)) {
|
||||
const next = lines.slice(index + 1, index + 6).map((value) => value.trim())
|
||||
return next.some((value) => /^(Sent|Date):\s+\S/i.test(value))
|
||||
&& next.some((value) => /^To:\s+\S/i.test(value))
|
||||
&& next.some((value) => /^Subject:\s+\S/i.test(value))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function stripQuotedReplyText(text: string): string {
|
||||
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
|
||||
const boundary = lines.findIndex((line, index) => {
|
||||
if (isReplyQuoteBoundary(lines, index)) return true
|
||||
return index > 0
|
||||
&& line.trim().startsWith('>')
|
||||
&& (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>'))
|
||||
})
|
||||
const visible = boundary >= 0 ? lines.slice(0, boundary) : lines
|
||||
return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
function getInitial(from?: string): string {
|
||||
return (extractName(from)[0] || '?').toUpperCase()
|
||||
}
|
||||
|
|
@ -111,112 +79,6 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
|
|||
return thread.messages[thread.messages.length - 1]
|
||||
}
|
||||
|
||||
// Split a raw header recipient string (e.g. `"Jo Bloggs" <jo@x.com>, b@y.com`) into
|
||||
// individual address tokens, respecting commas inside quotes/angle brackets.
|
||||
function splitAddresses(raw?: string): string[] {
|
||||
if (!raw) return []
|
||||
const tokens: string[] = []
|
||||
let buf = ''
|
||||
let inQuote = false
|
||||
let depth = 0
|
||||
for (const ch of raw) {
|
||||
if (ch === '"') inQuote = !inQuote
|
||||
else if (ch === '<') depth += 1
|
||||
else if (ch === '>') depth = Math.max(0, depth - 1)
|
||||
if ((ch === ',' || ch === ';' || ch === '\n') && !inQuote && depth === 0) {
|
||||
const token = buf.trim()
|
||||
if (token) tokens.push(token)
|
||||
buf = ''
|
||||
continue
|
||||
}
|
||||
buf += ch
|
||||
}
|
||||
const last = buf.trim()
|
||||
if (last) tokens.push(last)
|
||||
return tokens
|
||||
}
|
||||
|
||||
// Display label for a recipient chip: the display name if present, else the bare address.
|
||||
function recipientLabel(token: string): string {
|
||||
const named = token.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/)
|
||||
if (named?.[1]?.trim()) return named[1].trim()
|
||||
return extractAddress(token)
|
||||
}
|
||||
|
||||
// Dedupe tokens by lowercased email address, dropping any whose address is in `exclude`.
|
||||
function dedupeRecipients(tokens: string[], exclude: Set<string>): string[] {
|
||||
const seen = new Set<string>(exclude)
|
||||
const out: string[] = []
|
||||
for (const token of tokens) {
|
||||
const addr = extractAddress(token).toLowerCase()
|
||||
if (!addr || seen.has(addr)) continue
|
||||
seen.add(addr)
|
||||
out.push(token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Compute the To / Cc recipients for a reply, reply-all, or forward, excluding "me".
|
||||
function buildRecipients(
|
||||
mode: ComposeMode,
|
||||
thread: GmailThread,
|
||||
selfEmail: string,
|
||||
): { to: string[]; cc: string[] } {
|
||||
if (mode === 'forward') return { to: [], cc: [] }
|
||||
|
||||
const latest = latestMessage(thread)
|
||||
const self = selfEmail.toLowerCase()
|
||||
const fromAddr = latest?.from ? extractAddress(latest.from).toLowerCase() : ''
|
||||
const iAmSender = Boolean(self) && fromAddr === self
|
||||
|
||||
// If my own message is the latest, reply to whoever I sent it to; otherwise reply to the sender.
|
||||
const rawTo = iAmSender ? splitAddresses(latest?.to) : (latest?.from ? [latest.from] : [])
|
||||
const ccPool = iAmSender
|
||||
? splitAddresses(latest?.cc)
|
||||
: [...splitAddresses(latest?.to), ...splitAddresses(latest?.cc)]
|
||||
|
||||
const selfSet = new Set<string>(self ? [self] : [])
|
||||
const to = dedupeRecipients(rawTo, selfSet)
|
||||
if (iAmSender && to.length === 0 && self && rawTo.some((token) => extractAddress(token).toLowerCase() === self)) {
|
||||
to.push(self)
|
||||
}
|
||||
|
||||
if (mode === 'reply') return { to, cc: [] }
|
||||
|
||||
const ccExclude = new Set<string>(selfSet)
|
||||
for (const token of to) ccExclude.add(extractAddress(token).toLowerCase())
|
||||
const cc = dedupeRecipients(ccPool, ccExclude)
|
||||
return { to, cc }
|
||||
}
|
||||
|
||||
// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes.
|
||||
function composeSubject(mode: ComposeMode, rawSubject?: string): string {
|
||||
const raw = (rawSubject || '').trim()
|
||||
if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim()
|
||||
return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim()
|
||||
}
|
||||
|
||||
function buildForwardedContent(thread: GmailThread): string {
|
||||
const message = latestMessage(thread)
|
||||
if (!message) return ''
|
||||
const rows = [
|
||||
'---------- Forwarded message ---------',
|
||||
message.from ? `From: ${message.from}` : null,
|
||||
message.date ? `Date: ${formatFullDate(message.date)}` : null,
|
||||
message.subject || thread.subject ? `Subject: ${message.subject || thread.subject}` : null,
|
||||
message.to ? `To: ${message.to}` : null,
|
||||
message.cc ? `Cc: ${message.cc}` : null,
|
||||
].filter((line): line is string => Boolean(line))
|
||||
const body = (message.body || snippet(message.bodyHtml)).trim()
|
||||
return [
|
||||
'<p></p>',
|
||||
'<blockquote>',
|
||||
...rows.map((line) => `<p>${escapeHtml(line)}</p>`),
|
||||
body ? `<p>${escapeHtml(body).replace(/\n/g, '<br />')}</p>` : '',
|
||||
'</blockquote>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
const PREFETCH_HOVER_MS = 180
|
||||
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
|
||||
|
||||
|
|
@ -511,7 +373,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
|
|||
)
|
||||
}
|
||||
|
||||
type ComposeMode = 'reply' | 'replyAll' | 'forward'
|
||||
type ComposeMode = 'reply' | 'forward'
|
||||
|
||||
function ComposeToolbarButton({
|
||||
editor,
|
||||
|
|
@ -612,125 +474,35 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
)
|
||||
}
|
||||
|
||||
function RecipientField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
autoFocus,
|
||||
trailing,
|
||||
}: {
|
||||
label: string
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
autoFocus?: boolean
|
||||
trailing?: React.ReactNode
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus()
|
||||
}, [autoFocus])
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const additions = splitAddresses(raw)
|
||||
if (additions.length === 0) return
|
||||
onChange(dedupeRecipients([...value, ...additions], new Set()))
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
|
||||
if (draft.trim()) {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
}
|
||||
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gmail-recipient-row">
|
||||
<span className="gmail-recipient-label">{label}</span>
|
||||
<div className="gmail-recipient-field">
|
||||
{value.map((token, index) => (
|
||||
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||||
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-recipient-chip-remove"
|
||||
aria-label={`Remove ${extractAddress(token)}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onChange(value.filter((_, idx) => idx !== index))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="gmail-recipient-input"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => { if (draft.trim()) commit(draft) }}
|
||||
onPaste={(event) => {
|
||||
const text = event.clipboardData.getData('text')
|
||||
if (text && /[,;\n]/.test(text)) {
|
||||
event.preventDefault()
|
||||
commit(text)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposeBox({
|
||||
mode,
|
||||
thread,
|
||||
selfEmail,
|
||||
onClose,
|
||||
}: {
|
||||
mode: ComposeMode
|
||||
thread: GmailThread
|
||||
selfEmail: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const latest = latestMessage(thread)
|
||||
const initialRecipients = useMemo(
|
||||
() => buildRecipients(mode, thread, selfEmail),
|
||||
[mode, thread, selfEmail],
|
||||
)
|
||||
|
||||
const [toList, setToList] = useState<string[]>(initialRecipients.to)
|
||||
const [ccList, setCcList] = useState<string[]>(initialRecipients.cc)
|
||||
const [bccList, setBccList] = useState<string[]>([])
|
||||
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
|
||||
const [showBcc, setShowBcc] = useState<boolean>(false)
|
||||
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
|
||||
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
||||
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
|
||||
|
||||
const initialContent = useMemo(() => {
|
||||
if (mode === 'forward') return buildForwardedContent(thread)
|
||||
if (mode !== 'reply') return ''
|
||||
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
||||
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
|
||||
const source = thread.gmail_draft || thread.draft_response
|
||||
if (!source) return ''
|
||||
return source
|
||||
.split(/\n{2,}/)
|
||||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
}, [mode, thread])
|
||||
}, [mode, thread.gmail_draft, thread.draft_response])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false }),
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||||
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
|
|
@ -782,65 +554,52 @@ function ComposeBox({
|
|||
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
|
||||
}
|
||||
|
||||
const [sending, setSending] = useState(false)
|
||||
const sendInGmail = async () => {
|
||||
if (!editor || sending) return
|
||||
if (!editor) {
|
||||
window.open(thread.threadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
const html = editor.getHTML()
|
||||
const text = editor.getText().trim()
|
||||
if (!text) {
|
||||
toast('Draft is empty.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (toList.length === 0) {
|
||||
toast('Add at least one recipient.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// Build References chain from all known message ids (newest last).
|
||||
const messageIds = thread.messages
|
||||
.map((m) => m.messageIdHeader)
|
||||
.filter((v): v is string => Boolean(v))
|
||||
const references = messageIds.join(' ')
|
||||
const inReplyTo = latest?.messageIdHeader
|
||||
const isForward = mode === 'forward'
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||
threadId: isForward ? undefined : thread.threadId,
|
||||
to: toList.join(', '),
|
||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||
subject: subject.trim() || composeSubject(mode, thread.subject),
|
||||
bodyHtml: html,
|
||||
bodyText: text,
|
||||
inReplyTo: isForward ? undefined : inReplyTo,
|
||||
references: isForward ? undefined : references || undefined,
|
||||
})
|
||||
if (result.error) {
|
||||
toast(`Send failed: ${result.error}`, 'error')
|
||||
return
|
||||
let copied = false
|
||||
if (text) {
|
||||
try {
|
||||
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/html': new Blob([html], { type: 'text/html' }),
|
||||
'text/plain': new Blob([text], { type: 'text/plain' }),
|
||||
}),
|
||||
])
|
||||
copied = true
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Gmail] clipboard write failed:', err)
|
||||
}
|
||||
toast('Sent.', 'success')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
window.open(thread.threadUrl, '_blank')
|
||||
if (copied) {
|
||||
toast('Draft copied — open the reply in Gmail and paste.', 'info')
|
||||
} else if (text) {
|
||||
toast('Could not copy draft. Open Gmail and paste manually.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const refineWithCopilot = () => {
|
||||
if (!editor) return
|
||||
const currentDraft = editor.getText().trim()
|
||||
const threadSubject = thread.subject || '(No subject)'
|
||||
const subject = thread.subject || '(No subject)'
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`)
|
||||
lines.push('')
|
||||
lines.push(`**Mode:** ${modeLabel}`)
|
||||
lines.push(`**Subject:** ${threadSubject}`)
|
||||
lines.push(`**Mode:** ${mode === 'reply' ? 'Reply' : 'Forward'}`)
|
||||
lines.push(`**Subject:** ${subject}`)
|
||||
lines.push('')
|
||||
lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`)
|
||||
lines.push('')
|
||||
|
|
@ -865,32 +624,17 @@ function ComposeBox({
|
|||
return (
|
||||
<div className="gmail-compose-card">
|
||||
<div className="gmail-compose-header">
|
||||
<span>{modeLabel}</span>
|
||||
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
|
||||
<span>{mode === 'reply' ? 'Reply' : 'Forward'}</span>
|
||||
<button type="button" onClick={onClose} aria-label="Close compose">x</button>
|
||||
</div>
|
||||
<div className="gmail-compose-line">
|
||||
<span>{mode === 'reply' ? 'To' : 'Recipients'}</span>
|
||||
<input value={to} placeholder="Recipients" readOnly={mode === 'reply'} />
|
||||
</div>
|
||||
<RecipientField
|
||||
label="To"
|
||||
value={toList}
|
||||
onChange={setToList}
|
||||
autoFocus={mode === 'forward'}
|
||||
trailing={
|
||||
<div className="gmail-recipient-toggles">
|
||||
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
|
||||
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
|
||||
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
|
||||
{mode === 'forward' && (
|
||||
<div className="gmail-compose-line">
|
||||
<span className="gmail-compose-label">Subject</span>
|
||||
<input
|
||||
className="gmail-compose-subject-input"
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Subject"
|
||||
/>
|
||||
<span>Subject</span>
|
||||
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
|
||||
</div>
|
||||
)}
|
||||
<EditorContent editor={editor} className="gmail-compose-editor" />
|
||||
|
|
@ -921,11 +665,10 @@ function ComposeBox({
|
|||
type="button"
|
||||
className="gmail-send-button"
|
||||
onClick={() => { void sendInGmail() }}
|
||||
disabled={sending}
|
||||
title="Send this reply via Gmail"
|
||||
title="Copy draft and open this thread in Gmail"
|
||||
>
|
||||
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
<Send size={15} />
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -954,25 +697,10 @@ function ThreadDetail({
|
|||
hidden?: boolean
|
||||
}) {
|
||||
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
|
||||
const [selfEmail, setSelfEmail] = useState<string>('')
|
||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(
|
||||
() => new Set(thread.messages.length > 0 ? [thread.messages.length - 1] : [])
|
||||
)
|
||||
|
||||
// The connected Gmail address, so reply-all can exclude "me".
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.ipc.invoke('gmail:getAccountEmail', {})
|
||||
.then((res) => { if (!cancelled && res?.email) setSelfEmail(res.email) })
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const canReplyAll = useMemo(() => {
|
||||
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
|
||||
return cc.length > 0 || to.length > 1
|
||||
}, [thread, selfEmail])
|
||||
|
||||
const toggleExpand = useCallback((index: number) => {
|
||||
setExpandedIndices((prev) => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -1023,10 +751,7 @@ function ThreadDetail({
|
|||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<div className="gmail-message-to">to {message.to || 'me'}</div>
|
||||
{message.cc && <div className="gmail-message-cc">cc {message.cc}</div>}
|
||||
</>
|
||||
<div className="gmail-message-to">to {message.to || 'me'}</div>
|
||||
) : (
|
||||
<div className="gmail-message-snippet">{snippet(message.body)}</div>
|
||||
)}
|
||||
|
|
@ -1045,12 +770,6 @@ function ThreadDetail({
|
|||
<Reply size={16} />
|
||||
Reply
|
||||
</button>
|
||||
{canReplyAll && (
|
||||
<button type="button" onClick={() => setComposeMode('replyAll')}>
|
||||
<ReplyAll size={16} />
|
||||
Reply all
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setComposeMode('forward')}>
|
||||
<Forward size={16} />
|
||||
Forward
|
||||
|
|
@ -1059,10 +778,8 @@ function ThreadDetail({
|
|||
|
||||
{composeMode && (
|
||||
<ComposeBox
|
||||
key={composeMode}
|
||||
mode={composeMode}
|
||||
thread={thread}
|
||||
selfEmail={selfEmail}
|
||||
onClose={() => setComposeMode(null)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1073,7 +790,8 @@ function ThreadDetail({
|
|||
|
||||
const MAX_KEPT_OPEN = 5
|
||||
const PAGE_SIZE = 25
|
||||
type InboxSection = 'important' | 'other'
|
||||
const SECTIONS = ['important', 'other'] as const
|
||||
type InboxSection = (typeof SECTIONS)[number]
|
||||
|
||||
interface SectionState {
|
||||
threads: GmailThread[]
|
||||
|
|
@ -1099,59 +817,15 @@ function clearLoadingFlag(state: SectionState | null): SectionState {
|
|||
return { ...state, loadingPage: false }
|
||||
}
|
||||
|
||||
export type EmailViewProps = {
|
||||
/** If provided, the view opens with this thread already expanded. */
|
||||
initialThreadId?: string | null
|
||||
/** Bump to re-focus on the same threadId after navigating away inside the view. */
|
||||
threadIdVersion?: number
|
||||
}
|
||||
|
||||
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
|
||||
export function EmailView() {
|
||||
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
|
||||
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
||||
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
|
||||
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>(initialThreadId ? [initialThreadId] : [])
|
||||
useEffect(() => {
|
||||
setSelectedThreadId(initialThreadId ?? null)
|
||||
if (initialThreadId) {
|
||||
setOpenedThreadIds((prev) => {
|
||||
const without = prev.filter((id) => id !== initialThreadId)
|
||||
return [...without, initialThreadId].slice(-MAX_KEPT_OPEN)
|
||||
})
|
||||
}
|
||||
}, [initialThreadId, threadIdVersion])
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
|
||||
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
|
||||
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
// Gmail sync uses the native Google OAuth connection.
|
||||
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const check = async () => {
|
||||
try {
|
||||
const status = await window.ipc.invoke('gmail:getConnectionStatus', {})
|
||||
if (!cancelled) setEmailConnection(status)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEmailConnection({
|
||||
connected: false,
|
||||
hasRequiredScope: false,
|
||||
missingScopes: [],
|
||||
email: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
void check()
|
||||
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanupOAuthConnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { persistedImportant = important }, [important])
|
||||
useEffect(() => { persistedOther = other }, [other])
|
||||
|
|
@ -1161,81 +835,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
else setOther(updater)
|
||||
}, [])
|
||||
|
||||
const updateThreadInState = useCallback((threadId: string, updater: (t: GmailThread) => GmailThread) => {
|
||||
const mapSection = (prev: SectionState): SectionState => ({
|
||||
...prev,
|
||||
threads: prev.threads.map((t) => (t.threadId === threadId ? updater(t) : t)),
|
||||
})
|
||||
setImportant(mapSection)
|
||||
setOther(mapSection)
|
||||
}, [])
|
||||
|
||||
const removeThreadFromState = useCallback((threadId: string) => {
|
||||
const filterSection = (prev: SectionState): SectionState => ({
|
||||
...prev,
|
||||
threads: prev.threads.filter((t) => t.threadId !== threadId),
|
||||
})
|
||||
setImportant(filterSection)
|
||||
setOther(filterSection)
|
||||
setSelectedThreadId((current) => (current === threadId ? null : current))
|
||||
setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId))
|
||||
}, [])
|
||||
|
||||
const markThreadReadAction = useCallback(async (threadId: string) => {
|
||||
updateThreadInState(threadId, (t) => ({
|
||||
...t,
|
||||
unread: false,
|
||||
messages: t.messages.map((m) => ({ ...m, unread: false })),
|
||||
}))
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId })
|
||||
if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error)
|
||||
} catch (err) {
|
||||
console.warn('[Gmail] mark-read failed:', err)
|
||||
}
|
||||
}, [updateThreadInState])
|
||||
|
||||
const archiveThreadAction = useCallback(async (threadId: string) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
||||
if (result.ok) {
|
||||
removeThreadFromState(threadId)
|
||||
} else if (result.error) {
|
||||
toast(`Archive failed: ${result.error}`, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
}
|
||||
}, [removeThreadFromState])
|
||||
|
||||
const trashThreadAction = useCallback(async (threadId: string) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
|
||||
if (result.ok) {
|
||||
removeThreadFromState(threadId)
|
||||
} else if (result.error) {
|
||||
toast(`Delete failed: ${result.error}`, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
}
|
||||
}, [removeThreadFromState])
|
||||
|
||||
const toggleThread = useCallback((thread: GmailThread) => {
|
||||
const toggleThread = useCallback((threadId: string) => {
|
||||
setSelectedThreadId((current) => {
|
||||
const next = current === thread.threadId ? null : thread.threadId
|
||||
const next = current === threadId ? null : threadId
|
||||
if (next) {
|
||||
setOpenedThreadIds((prev) => {
|
||||
const without = prev.filter((id) => id !== next)
|
||||
return [...without, next].slice(-MAX_KEPT_OPEN)
|
||||
})
|
||||
if (thread.unread) {
|
||||
void markThreadReadAction(thread.threadId)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [markThreadReadAction])
|
||||
}, [])
|
||||
|
||||
const prefetchedRef = useRef<Set<string>>(new Set())
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
|
@ -1482,69 +1093,29 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
|
||||
const hasAny = important.threads.length > 0 || other.threads.length > 0
|
||||
const initialLoading = !hasAny && refreshing
|
||||
const needsEmailConnect = emailConnection?.connected === false
|
||||
const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope
|
||||
|
||||
const renderRow = (thread: GmailThread) => {
|
||||
const latest = latestMessage(thread)
|
||||
const isSelected = thread.threadId === selectedThreadId
|
||||
const isUnread = thread.unread === true
|
||||
const isMounted = openedThreadIds.includes(thread.threadId)
|
||||
const stop = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
return (
|
||||
<div key={thread.threadId} className="gmail-row-group">
|
||||
<div
|
||||
className={cn('gmail-row-shell', isSelected && 'gmail-row-shell-selected')}
|
||||
<button
|
||||
type="button"
|
||||
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
|
||||
onClick={() => toggleThread(thread.threadId)}
|
||||
onMouseEnter={() => scheduleHoverPrefetch(thread)}
|
||||
onMouseLeave={cancelHoverPrefetch}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
|
||||
onClick={() => toggleThread(thread)}
|
||||
>
|
||||
<span className="gmail-row-dot" aria-hidden />
|
||||
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
|
||||
<span className="gmail-row-content">
|
||||
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
|
||||
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
|
||||
</span>
|
||||
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
|
||||
</button>
|
||||
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
|
||||
{isUnread && (
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action"
|
||||
title="Mark as read"
|
||||
aria-label="Mark as read"
|
||||
onClick={(e) => { stop(e); void markThreadReadAction(thread.threadId) }}
|
||||
>
|
||||
<CheckCheck size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action"
|
||||
title="Archive"
|
||||
aria-label="Archive"
|
||||
onClick={(e) => { stop(e); void archiveThreadAction(thread.threadId) }}
|
||||
>
|
||||
<Archive size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action gmail-row-action-danger"
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
onClick={(e) => { stop(e); void trashThreadAction(thread.threadId) }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="gmail-row-dot" aria-hidden />
|
||||
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
|
||||
<span className="gmail-row-content">
|
||||
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
|
||||
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
|
||||
</span>
|
||||
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
|
||||
</button>
|
||||
{isMounted && (
|
||||
<ThreadDetail
|
||||
thread={thread}
|
||||
|
|
@ -1614,30 +1185,12 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : needsEmailConnect || needsEmailReconnect ? (
|
||||
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
|
||||
<Mail size={28} className="opacity-50" />
|
||||
<p>
|
||||
{needsEmailReconnect
|
||||
? 'Reconnect your email to enable Gmail sync and actions.'
|
||||
: 'Connect your email to see your inbox here.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<Mail size={15} />
|
||||
{needsEmailReconnect ? 'Reconnect your email' : 'Connect your email'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gmail-empty-state">
|
||||
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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,11 +1,33 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
const MAX_SIZE_BYTES = 5 * 1024 * 1024
|
||||
const CACHE_MAX_ENTRIES = 20
|
||||
|
||||
type CacheEntry = { html: string; mtimeMs: number; size: number }
|
||||
const htmlCache = new Map<string, CacheEntry>()
|
||||
|
||||
function getCached(path: string, mtimeMs: number, size: number): string | null {
|
||||
const entry = htmlCache.get(path)
|
||||
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
|
||||
// Refresh LRU position
|
||||
htmlCache.delete(path)
|
||||
htmlCache.set(path, entry)
|
||||
return entry.html
|
||||
}
|
||||
|
||||
function setCached(path: string, html: string, mtimeMs: number, size: number) {
|
||||
htmlCache.set(path, { html, mtimeMs, size })
|
||||
while (htmlCache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldest = htmlCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
htmlCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
type ViewerState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded' }
|
||||
| { kind: 'loaded'; html: string }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'tooLarge'; sizeMB: number }
|
||||
| { kind: 'error'; message: string }
|
||||
|
|
@ -14,15 +36,9 @@ 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
|
||||
|
|
@ -41,11 +57,19 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
||||
return
|
||||
}
|
||||
if (stat.size === 0) {
|
||||
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
|
||||
if (cachedHtml !== null) {
|
||||
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
|
||||
return
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
if (cancelled) return
|
||||
setCached(path, result.data, stat.mtimeMs, stat.size)
|
||||
if (!result.data || result.data.trim() === '') {
|
||||
setState({ kind: 'empty' })
|
||||
return
|
||||
}
|
||||
setState({ kind: 'loaded' })
|
||||
setState({ kind: 'loaded', html: result.data })
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
@ -100,16 +124,20 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// 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).
|
||||
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
|
||||
// gets a null origin with no base URL. Trade-off: relative assets inside
|
||||
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
|
||||
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
|
||||
// works fine; HTML that ships next to sibling assets will look broken.
|
||||
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
|
||||
// support; that path also resolves through the existing path-traversal
|
||||
// guard in resolveWorkspacePath.
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{state.kind === 'loaded' && (
|
||||
<iframe
|
||||
key={path}
|
||||
src={iframeSrc}
|
||||
srcDoc={state.html}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
|
|
|
|||
|
|
@ -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,5 +1,5 @@
|
|||
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
|
||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -83,8 +83,7 @@ function nodeToText(node: JsonNode): string {
|
|||
return text
|
||||
} else if (child.type === 'wikiLink') {
|
||||
const path = (child.attrs?.path as string) || ''
|
||||
const label = (child.attrs?.label as string | null | undefined) || ''
|
||||
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
|
||||
return path ? `[[${path}]]` : ''
|
||||
} else if (child.type === 'hardBreak') {
|
||||
return '\n'
|
||||
}
|
||||
|
|
@ -190,8 +189,7 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '---'
|
||||
case 'wikiLink': {
|
||||
const path = (node.attrs?.path as string) || ''
|
||||
const label = (node.attrs?.label as string | null | undefined) || ''
|
||||
return `[[${path}${label ? `|${label}` : ''}]]`
|
||||
return `[[${path}]]`
|
||||
}
|
||||
case 'image': {
|
||||
const src = (node.attrs?.src as string) || ''
|
||||
|
|
@ -299,7 +297,7 @@ import { FrontmatterProperties } from './frontmatter-properties'
|
|||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||
import { RowboatMentionPopover } from './rowboat-mention-popover'
|
||||
import '@/styles/editor.css'
|
||||
|
|
@ -525,106 +523,6 @@ const TabIndentExtension = Extension.create({
|
|||
},
|
||||
})
|
||||
|
||||
const slugifyHeading = (text: string) =>
|
||||
text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
|
||||
const decodeLinkTarget = (target: string) => {
|
||||
try {
|
||||
return decodeURIComponent(target)
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToHeading = (view: EditorView, rawTarget: string) => {
|
||||
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
|
||||
if (!target) return false
|
||||
|
||||
const targetSlug = slugifyHeading(target)
|
||||
let foundPos: number | null = null
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'heading') return true
|
||||
const headingText = node.textContent.trim()
|
||||
if (
|
||||
headingText.toLowerCase() === target.toLowerCase()
|
||||
|| slugifyHeading(headingText) === targetSlug
|
||||
) {
|
||||
foundPos = pos
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (foundPos === null) return false
|
||||
|
||||
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
|
||||
)
|
||||
view.focus()
|
||||
|
||||
const domAtPos = view.domAtPos(foundPos + 1)
|
||||
const node = domAtPos.node
|
||||
const headingEl = node.nodeType === Node.ELEMENT_NODE
|
||||
? (node as HTMLElement)
|
||||
: node.parentElement
|
||||
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
return true
|
||||
}
|
||||
|
||||
const stripMarkdownExtension = (path: string) =>
|
||||
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
|
||||
|
||||
const isSameNotePath = (linkPath: string, notePath?: string) => {
|
||||
if (!notePath) return false
|
||||
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
|
||||
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
|
||||
return normalizedLink === normalizedNote
|
||||
}
|
||||
|
||||
const isExternalHref = (href: string) =>
|
||||
/^(https?:|mailto:|tel:)/i.test(href)
|
||||
|
||||
const collapseRelativeSegments = (relPath: string) => {
|
||||
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
|
||||
const stack: string[] = []
|
||||
for (const part of parts) {
|
||||
if (part === '..') {
|
||||
if (stack.length === 0) return null
|
||||
stack.pop()
|
||||
} else {
|
||||
stack.push(part)
|
||||
}
|
||||
}
|
||||
return stack.join('/')
|
||||
}
|
||||
|
||||
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
|
||||
const withoutHash = href.split('#')[0]
|
||||
const withoutQuery = withoutHash.split('?')[0]
|
||||
const decoded = decodeLinkTarget(withoutQuery)
|
||||
if (!decoded) return null
|
||||
|
||||
if (/^file:\/\//i.test(decoded)) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(decoded).pathname)
|
||||
} catch {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
|
||||
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
|
||||
|
||||
const noteDir = notePath.split('/').slice(0, -1).join('/')
|
||||
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
|
||||
}
|
||||
|
||||
export interface MarkdownEditorHandle {
|
||||
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
|
||||
getCursorContext: () => { path: string; lineNumber: number } | null
|
||||
|
|
@ -648,13 +546,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}, ref) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
// Read wikiLinks lazily inside the editor config via this ref. wikiLinks changes
|
||||
// identity whenever the workspace directory tree changes (file watcher → new file
|
||||
// list), and it used to be a useEditor() dependency — so any background write to
|
||||
// the workspace destroyed and recreated the entire editor, resetting scroll to the
|
||||
// top. Keeping it off the dep array (and reading the ref at event time) means the
|
||||
// editor instance survives directory changes.
|
||||
const wikiLinksRef = useRef(wikiLinks)
|
||||
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
||||
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
||||
|
|
@ -677,7 +568,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
|
||||
// Keep ref in sync with state for the plugin to access
|
||||
selectionHighlightRef.current = selectionHighlight
|
||||
wikiLinksRef.current = wikiLinks
|
||||
|
||||
// Memoize the selection highlight extension
|
||||
const selectionHighlightExtension = useMemo(
|
||||
|
|
@ -754,7 +644,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
|
|
@ -784,9 +673,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
TranscriptBlockExtension,
|
||||
MermaidBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: (path: string) => {
|
||||
void wikiLinksRef.current?.onCreate?.(path)
|
||||
},
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path: string) => {
|
||||
void wikiLinks.onCreate(path)
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
|
|
@ -913,57 +804,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||
if (node.type.name === 'wikiLink') {
|
||||
event.preventDefault()
|
||||
const wikiPath = String(node.attrs.path ?? '')
|
||||
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
|
||||
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
|
||||
return scrollToHeading(_view, heading)
|
||||
}
|
||||
wikiLinksRef.current?.onOpen?.(node.attrs.path)
|
||||
wikiLinks?.onOpen?.(node.attrs.path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDOMEvents: {
|
||||
click: (view, event) => {
|
||||
const target = event.target as Element | null
|
||||
const link = target?.closest('a[href]') as HTMLAnchorElement | null
|
||||
if (!link) return false
|
||||
if (link.dataset.type === 'wiki-link') return false
|
||||
|
||||
const href = link.getAttribute('href') ?? ''
|
||||
if (!href) return false
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
event.preventDefault()
|
||||
return scrollToHeading(view, href)
|
||||
}
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
event.preventDefault()
|
||||
window.open(href, '_blank')
|
||||
return true
|
||||
}
|
||||
|
||||
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
|
||||
if (!workspacePath) return false
|
||||
|
||||
event.preventDefault()
|
||||
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
|
||||
if (result.error) console.error('Failed to open linked file:', result.error)
|
||||
}).catch((err) => {
|
||||
console.error('Failed to open linked file:', err)
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
// NOTE: wikiLinks is intentionally NOT a dependency — it's read via wikiLinksRef
|
||||
// at event time. Including it rebuilds the whole editor on every directory change
|
||||
// (file watcher), which resets scroll to the top. See wikiLinksRef declaration.
|
||||
}, [
|
||||
editorSessionKey,
|
||||
maybeCommitPrimaryHeading,
|
||||
notePath,
|
||||
preventTitleHeadingDemotion,
|
||||
promoteFirstParagraphToTitleHeading,
|
||||
])
|
||||
|
|
@ -1211,37 +1060,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
// Normalize for comparison (trim trailing whitespace from lines)
|
||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
// Preserve scroll + selection across an external content sync. setContent()
|
||||
// resets the selection to the top of the doc and ProseMirror scrolls it into
|
||||
// view; without restoring, a background writer touching the open file (graph
|
||||
// builder, live-note runner, version-history commit) yanks the viewport back
|
||||
// to the top repeatedly — making the note impossible to scroll. This editor
|
||||
// instance is bound to a single note path, so the prior scrollTop is always
|
||||
// valid for the reloaded content.
|
||||
const wrapper = wrapperRef.current
|
||||
const prevScrollTop = wrapper?.scrollTop ?? 0
|
||||
const hadFocus = editor.isFocused
|
||||
const { from: prevFrom, to: prevTo } = editor.state.selection
|
||||
|
||||
isInternalUpdate.current = true
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
|
||||
// Only restore the caret for a focused editor, so we never steal focus or
|
||||
// scroll for a passive viewer. Clamp to the (possibly shorter) new doc.
|
||||
if (hadFocus) {
|
||||
const docSize = editor.state.doc.content.size
|
||||
const from = Math.min(prevFrom, docSize)
|
||||
const to = Math.min(prevTo, docSize)
|
||||
try {
|
||||
editor.chain().setMeta('addToHistory', false).setTextSelection({ from, to }).run()
|
||||
} catch { /* selection no longer valid in the new doc — ignore */ }
|
||||
}
|
||||
isInternalUpdate.current = false
|
||||
|
||||
// Restore scroll last so it wins over any scrollIntoView triggered above.
|
||||
if (wrapper) wrapper.scrollTop = prevScrollTop
|
||||
}
|
||||
}
|
||||
}, [editor, content])
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -42,32 +39,14 @@ type RawCalendarEvent = {
|
|||
start?: { dateTime?: string; date?: string }
|
||||
end?: { dateTime?: string; date?: string }
|
||||
location?: string
|
||||
description?: string
|
||||
htmlLink?: string
|
||||
status?: string
|
||||
creator?: CalendarPerson
|
||||
organizer?: CalendarPerson
|
||||
attendees?: CalendarAttendee[]
|
||||
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>
|
||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||
hangoutLink?: string
|
||||
conferenceLink?: string
|
||||
}
|
||||
|
||||
type CalendarPerson = {
|
||||
email?: string
|
||||
displayName?: string
|
||||
self?: boolean
|
||||
}
|
||||
|
||||
type CalendarAttendee = CalendarPerson & {
|
||||
responseStatus?: string
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
type DescriptionPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'link'; text: string; href: string }
|
||||
|
||||
type UpcomingEvent = {
|
||||
id: string
|
||||
summary: string
|
||||
|
|
@ -75,12 +54,8 @@ type UpcomingEvent = {
|
|||
end: Date | null
|
||||
isAllDay: boolean
|
||||
location: string | null
|
||||
description: string | null
|
||||
htmlLink: string | null
|
||||
conferenceLink: string | null
|
||||
creator: CalendarPerson | null
|
||||
organizer: CalendarPerson | null
|
||||
attendees: CalendarAttendee[]
|
||||
source: string // workspace path to the calendar_sync JSON
|
||||
rawStart: { dateTime?: string; date?: string } | undefined
|
||||
rawEnd: { dateTime?: string; date?: string } | undefined
|
||||
|
|
@ -149,12 +124,8 @@ function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEven
|
|||
end,
|
||||
isAllDay,
|
||||
location: raw.location?.trim() || null,
|
||||
description: raw.description?.trim() || null,
|
||||
htmlLink: raw.htmlLink ?? null,
|
||||
conferenceLink,
|
||||
creator: raw.creator ?? null,
|
||||
organizer: raw.organizer ?? null,
|
||||
attendees: raw.attendees ?? [],
|
||||
source: sourcePath,
|
||||
rawStart: raw.start,
|
||||
rawEnd: raw.end,
|
||||
|
|
@ -213,177 +184,11 @@ function formatEventTimeRange(event: UpcomingEvent): string {
|
|||
return `${start} – ${end}`
|
||||
}
|
||||
|
||||
// Compact range for the upcoming list: drops the leading meridiem when both
|
||||
// ends share it ("9:00 – 11:00 AM" instead of "9:00 AM – 11:00 AM").
|
||||
function formatEventTimeRangeCompact(event: UpcomingEvent): string {
|
||||
if (event.isAllDay) return 'All day'
|
||||
const startStr = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
if (!event.end) return startStr
|
||||
const sameDay = localDateKey(event.start) === localDateKey(event.end)
|
||||
if (!sameDay) return formatEventTimeRange(event)
|
||||
const endStr = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
const meridiemRe = /\s*[AP]M$/i
|
||||
const startMer = startStr.match(meridiemRe)?.[0]?.trim().toUpperCase()
|
||||
const endMer = endStr.match(meridiemRe)?.[0]?.trim().toUpperCase()
|
||||
if (startMer && endMer && startMer === endMer) {
|
||||
return `${startStr.replace(meridiemRe, '')} – ${endStr}`
|
||||
}
|
||||
return `${startStr} – ${endStr}`
|
||||
}
|
||||
|
||||
// Whether a timed event is happening right now.
|
||||
function isEventNow(event: UpcomingEvent): boolean {
|
||||
if (event.isAllDay) return false
|
||||
const now = Date.now()
|
||||
const start = event.start.getTime()
|
||||
const end = event.end ? event.end.getTime() : start + 30 * 60 * 1000
|
||||
return start <= now && now < end
|
||||
}
|
||||
|
||||
// Human label for the conferencing provider behind an event's join link.
|
||||
function meetingPlatformLabel(link: string | null): string | null {
|
||||
if (!link) return null
|
||||
if (/zoom\.us|zoomgov\.com/i.test(link)) return 'Zoom'
|
||||
if (/teams\.(?:microsoft|live)\.com/i.test(link)) return 'Teams'
|
||||
if (/meet\.google\.com/i.test(link)) return 'Meet'
|
||||
return 'Video call'
|
||||
}
|
||||
|
||||
function formatEventDetailTime(event: UpcomingEvent): string {
|
||||
if (!event.isAllDay) {
|
||||
const date = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
return `${date}, ${formatEventTimeRange(event)}`
|
||||
}
|
||||
|
||||
const start = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
if (!event.end) return `${start}, all day`
|
||||
|
||||
const exclusiveEnd = addDays(event.end, -1)
|
||||
if (localDateKey(exclusiveEnd) === localDateKey(event.start)) return `${start}, all day`
|
||||
|
||||
const end = exclusiveEnd.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
return `${start} – ${end}, all day`
|
||||
}
|
||||
|
||||
function personLabel(person: CalendarPerson | null | undefined): string | null {
|
||||
if (!person) return null
|
||||
return person.displayName?.trim() || person.email?.trim() || null
|
||||
}
|
||||
|
||||
function attendeeLabel(attendee: CalendarAttendee): string | null {
|
||||
const label = personLabel(attendee)
|
||||
if (!label) return null
|
||||
if (attendee.self) return `${label} (you)`
|
||||
return label
|
||||
}
|
||||
|
||||
function normalizeDescriptionParts(parts: DescriptionPart[]): DescriptionPart[] {
|
||||
const normalized: DescriptionPart[] = []
|
||||
for (const part of parts) {
|
||||
const text = part.text.replace(/\n{3,}/g, '\n\n')
|
||||
if (!text) continue
|
||||
const previous = normalized[normalized.length - 1]
|
||||
if (previous?.type === 'text' && part.type === 'text') {
|
||||
previous.text += text
|
||||
} else if (part.type === 'link') {
|
||||
normalized.push({ ...part, text })
|
||||
} else {
|
||||
normalized.push({ type: 'text', text })
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isSafeDescriptionHref(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value, window.location.href)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function linkifyText(value: string): DescriptionPart[] {
|
||||
const parts: DescriptionPart[] = []
|
||||
const urlRe = /\bhttps?:\/\/[^\s<>"')\]]+|\bwww\.[^\s<>"')\]]+/gi
|
||||
let lastIndex = 0
|
||||
for (const match of value.matchAll(urlRe)) {
|
||||
const raw = match[0]
|
||||
const index = match.index ?? 0
|
||||
if (index > lastIndex) parts.push({ type: 'text', text: value.slice(lastIndex, index) })
|
||||
const href = raw.startsWith('www.') ? `https://${raw}` : raw
|
||||
parts.push({ type: 'link', text: raw, href })
|
||||
lastIndex = index + raw.length
|
||||
}
|
||||
if (lastIndex < value.length) parts.push({ type: 'text', text: value.slice(lastIndex) })
|
||||
return parts
|
||||
}
|
||||
|
||||
function parseDescriptionParts(value: string): DescriptionPart[] {
|
||||
const withLineBreaks = value.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/\s*(p|div|li|tr|h[1-6])\s*>/gi, '\n')
|
||||
if (typeof DOMParser === 'undefined') {
|
||||
return normalizeDescriptionParts(linkifyText(withLineBreaks.replace(/<[^>]*>/g, '').trim()))
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(withLineBreaks, 'text/html')
|
||||
const parts: DescriptionPart[] = []
|
||||
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
parts.push(...linkifyText(node.textContent ?? ''))
|
||||
return
|
||||
}
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
if (node.tagName === 'A') {
|
||||
const href = node.getAttribute('href') ?? ''
|
||||
const text = node.textContent?.trim() || href
|
||||
if (href && isSafeDescriptionHref(href)) {
|
||||
parts.push({ type: 'link', text, href })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (node.tagName === 'BR') {
|
||||
parts.push({ type: 'text', text: '\n' })
|
||||
return
|
||||
}
|
||||
node.childNodes.forEach(visit)
|
||||
if (/^(P|DIV|LI|TR|H[1-6])$/.test(node.tagName)) {
|
||||
parts.push({ type: 'text', text: '\n' })
|
||||
}
|
||||
}
|
||||
|
||||
doc.body.childNodes.forEach(visit)
|
||||
return normalizeDescriptionParts(parts).map((part, index, all) => {
|
||||
if (index === 0 || index === all.length - 1) return { ...part, text: part.text.trim() }
|
||||
return part
|
||||
}).filter((part) => part.text.length > 0)
|
||||
}
|
||||
|
||||
function UpcomingEvents() {
|
||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
// Calendar sync uses the native Google OAuth connection.
|
||||
const [calendarConnected, setCalendarConnected] = useState<boolean | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const check = async () => {
|
||||
try {
|
||||
const oauthState = await window.ipc.invoke('oauth:getState', null)
|
||||
if (!cancelled) setCalendarConnected(oauthState.config?.google?.connected ?? false)
|
||||
} catch {
|
||||
if (!cancelled) setCalendarConnected(false)
|
||||
}
|
||||
}
|
||||
void check()
|
||||
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanupOAuthConnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -468,9 +273,8 @@ function UpcomingEvents() {
|
|||
break
|
||||
}
|
||||
})
|
||||
// Refresh every minute so the "now" highlight, day labels, and "ended"
|
||||
// filtering stay current without waiting on a calendar sync.
|
||||
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 1000)
|
||||
// Refresh on the hour so day labels and "ended" filtering stay current.
|
||||
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000)
|
||||
return () => {
|
||||
cleanup()
|
||||
clearInterval(tick)
|
||||
|
|
@ -500,284 +304,164 @@ function UpcomingEvents() {
|
|||
Coming up
|
||||
</h3>
|
||||
{loading && events.length === 0 ? null : (
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<span
|
||||
className="text-[11px] uppercase tracking-wider"
|
||||
style={{ color: 'var(--gm-text-faint)' }}
|
||||
>
|
||||
{totalVisible} {totalVisible === 1 ? 'event' : 'events'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{calendarConnected === false && events.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<Calendar className="size-7 text-muted-foreground opacity-50" />
|
||||
<p className="text-sm text-muted-foreground">Connect your calendar to see upcoming meetings here.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<Calendar className="size-4" />
|
||||
Connect your calendar
|
||||
</button>
|
||||
</div>
|
||||
) : loading && events.length === 0 ? (
|
||||
{loading && events.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-4 text-sm text-muted-foreground">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{visibleDays.map((day) => (
|
||||
<UpcomingDayCard
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ borderColor: 'var(--gm-border)', background: 'var(--gm-bg)' }}
|
||||
>
|
||||
{visibleDays.map((day, idx) => (
|
||||
<UpcomingDayRow
|
||||
key={day.dateKey}
|
||||
day={day}
|
||||
isToday={day.dateKey === todayKey}
|
||||
isLast={idx === visibleDays.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
|
||||
function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) {
|
||||
const dayNum = day.date.getDate()
|
||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||
const count = day.events.length
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 border-b bg-muted px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="text-[22px] font-bold leading-none text-foreground">{dayNum}</span>
|
||||
<span className="truncate text-[13px] text-muted-foreground">
|
||||
{month} · {weekday}
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '96px 1fr',
|
||||
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 px-4 py-4">
|
||||
<span
|
||||
className="leading-none"
|
||||
style={{ fontSize: 30, fontWeight: 400, color: 'var(--gm-text-strong)' }}
|
||||
>
|
||||
{dayNum}
|
||||
</span>
|
||||
<span className="flex flex-col leading-tight">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
style={{ fontSize: 12, fontWeight: 600, color: 'var(--gm-text)' }}
|
||||
>
|
||||
{month}
|
||||
{isToday ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block rounded-full"
|
||||
style={{ width: 5, height: 5, background: 'var(--gm-accent)' }}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{isToday ? (
|
||||
<span className="shrink-0 rounded-md bg-foreground px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-background">
|
||||
Today
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{count} {count === 1 ? 'event' : 'events'}
|
||||
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{count === 0 ? (
|
||||
<div className="px-5 py-4 text-sm text-muted-foreground">
|
||||
{isToday ? 'No events today' : 'No events'}
|
||||
</div>
|
||||
) : (
|
||||
day.events.map((ev, idx) => (
|
||||
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
|
||||
))
|
||||
)}
|
||||
<div className="flex flex-col py-3 pr-3">
|
||||
{day.events.length === 0 ? (
|
||||
<div
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
|
||||
style={{ color: 'var(--gm-text-faint)', minHeight: 40 }}
|
||||
>
|
||||
<span aria-hidden className="self-stretch shrink-0" style={{ width: 3 }} />
|
||||
<span>{isToday ? 'No events today' : 'No events'}</span>
|
||||
</div>
|
||||
) : (
|
||||
day.events.map((ev) => <UpcomingEventItem key={ev.id} event={ev} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NowBadge() {
|
||||
return (
|
||||
<span className="shrink-0 rounded bg-green-600 px-1.5 py-px text-[10px] font-bold uppercase leading-[1.5] tracking-wide text-white">
|
||||
Now
|
||||
</span>
|
||||
)
|
||||
}
|
||||
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
|
||||
const handleOpen = useCallback(() => {
|
||||
if (event.htmlLink) window.open(event.htmlLink, '_blank')
|
||||
}, [event.htmlLink])
|
||||
|
||||
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isNow = isEventNow(event)
|
||||
const platform = meetingPlatformLabel(event.conferenceLink)
|
||||
const subtitle = platform ?? event.location
|
||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
||||
!isLast && 'border-b',
|
||||
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-[13px] tabular-nums text-muted-foreground" style={{ width: 118 }}>
|
||||
{formatEventTimeRangeCompact(event)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-foreground">
|
||||
{event.summary}
|
||||
</span>
|
||||
{isNow ? <NowBadge /> : null}
|
||||
</span>
|
||||
{subtitle ? (
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{platform ? <Video className="size-3.5 shrink-0" /> : <MapPin className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{subtitle}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
onNotesOnly={() => triggerMeetingCapture(event, false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<Mic className="size-3.5" />
|
||||
Take notes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose: () => void }) {
|
||||
const organizer = personLabel(event.organizer) ?? personLabel(event.creator)
|
||||
const attendees = event.attendees.map(attendeeLabel).filter((label): label is string => Boolean(label))
|
||||
const descriptionParts = event.description ? parseDescriptionParts(event.description) : []
|
||||
const handleMeetingCapture = (openConference: boolean) => {
|
||||
onClose()
|
||||
triggerMeetingCapture(event, openConference)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="w-[min(380px,calc(100vw-32px))] rounded-lg p-0 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--muted, #f4f4f5)',
|
||||
borderColor: 'var(--border, #e4e4e7)',
|
||||
color: 'var(--popover-foreground, #09090b)',
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen()
|
||||
}
|
||||
}}
|
||||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
|
||||
)}
|
||||
style={{ color: 'var(--gm-text)', minHeight: 40 }}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1 border-b px-3 py-2" style={{ borderColor: 'var(--border, #e4e4e7)' }}>
|
||||
{event.htmlLink ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="self-stretch rounded-full"
|
||||
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate"
|
||||
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
|
||||
>
|
||||
{event.summary}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 block truncate"
|
||||
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
|
||||
>
|
||||
{formatEventTimeRange(event)}
|
||||
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
onNotesOnly={() => triggerMeetingCapture(event, false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(event.htmlLink!, '_blank')}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: 'var(--muted-foreground, #71717a)' }}
|
||||
aria-label="Open in Google Calendar"
|
||||
title="Open in Google Calendar"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
style={{
|
||||
background: 'var(--gm-bg-pill)',
|
||||
color: 'var(--gm-text)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
<Mic className="size-3" />
|
||||
Take notes
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: 'var(--muted-foreground, #71717a)' }}
|
||||
aria-label="Close event details"
|
||||
title="Close"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1.5 h-3 w-3 shrink-0 rounded-sm"
|
||||
style={{ background: 'var(--primary, #18181b)' }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h4 className="break-words text-[20px] font-normal leading-6" style={{ color: 'var(--foreground, #09090b)' }}>
|
||||
{event.summary}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventDetailRow icon={<Clock className="size-4" />} value={formatEventDetailTime(event)} />
|
||||
{event.location ? <EventDetailRow icon={<MapPin className="size-4" />} value={event.location} /> : null}
|
||||
{organizer ? <EventDetailRow icon={<UserRound className="size-4" />} value={`Organizer: ${organizer}`} /> : null}
|
||||
{attendees.length > 0 ? (
|
||||
<EventDetailRow
|
||||
icon={<UsersRound className="size-4" />}
|
||||
value={attendees.slice(0, 8).join(', ') + (attendees.length > 8 ? `, +${attendees.length - 8} more` : '')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{event.conferenceLink ? (
|
||||
<div className="flex gap-3">
|
||||
<Video className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => handleMeetingCapture(true)}>
|
||||
Join & take notes
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
|
||||
Take notes only
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<Mic className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
|
||||
Take notes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{descriptionParts.length > 0 ? (
|
||||
<div className="flex gap-3">
|
||||
<span className="mt-1 size-4 shrink-0" />
|
||||
<div className="max-h-40 overflow-auto whitespace-pre-wrap break-words text-sm leading-5" style={{ color: 'var(--foreground, #27272a)' }}>
|
||||
{descriptionParts.map((part, index) => {
|
||||
if (part.type === 'text') return <span key={index}>{part.text}</span>
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={part.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.open(part.href, '_blank')
|
||||
}}
|
||||
className="underline underline-offset-2"
|
||||
style={{ color: 'var(--primary, #18181b)' }}
|
||||
>
|
||||
{part.text}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
|
||||
function EventDetailRow({ icon, value }: { icon: React.ReactNode; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3 text-sm leading-5">
|
||||
<span className="mt-0.5 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }}>{icon}</span>
|
||||
<span className="min-w-0 break-words" style={{ color: 'var(--foreground, #27272a)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -787,46 +471,41 @@ function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
|
|||
onNotesOnly: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// Fixed-position coords for the portaled menu so it isn't clipped by the
|
||||
// calendar card's `overflow-hidden`.
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null)
|
||||
|
||||
const updatePos = useCallback(() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
|
||||
}, [])
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updatePos()
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof globalThis.Node)) return
|
||||
if (containerRef.current?.contains(target) || menuRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
window.addEventListener('resize', updatePos)
|
||||
window.addEventListener('scroll', updatePos, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handler)
|
||||
window.removeEventListener('resize', updatePos)
|
||||
window.removeEventListener('scroll', updatePos, true)
|
||||
}
|
||||
}, [open, updatePos])
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-flex items-stretch">
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: 'relative', display: 'inline-flex', alignItems: 'stretch' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
|
||||
className="inline-flex items-center gap-1.5 rounded-l-md border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs transition-colors"
|
||||
style={{
|
||||
background: 'var(--gm-bg-pill)',
|
||||
color: 'var(--gm-text)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
borderTopLeftRadius: 6,
|
||||
borderBottomLeftRadius: 6,
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||
>
|
||||
<Video className="size-3.5" />
|
||||
<Video className="size-3" />
|
||||
Join & take notes
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -834,30 +513,49 @@ function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
|
|||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
|
||||
aria-label="More meeting options"
|
||||
className="inline-flex items-center justify-center rounded-r-md border border-l-0 bg-background px-1.5 py-1.5 text-foreground transition-colors hover:bg-accent"
|
||||
className="inline-flex items-center justify-center px-1.5 py-1 transition-colors"
|
||||
style={{
|
||||
background: 'var(--gm-bg-pill)',
|
||||
color: 'var(--gm-text)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
borderLeft: 'none',
|
||||
borderTopRightRadius: 6,
|
||||
borderBottomRightRadius: 6,
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
{open && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right, zIndex: 60 }}
|
||||
className="min-w-36 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
|
||||
className="flex w-full items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
<Mic className="size-3" />
|
||||
Take notes only
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
background: 'var(--gm-bg-card)',
|
||||
border: '1px solid var(--gm-border)',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
|
||||
minWidth: 144,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
|
||||
className="flex w-full items-center gap-1 px-2 py-1.5 text-xs"
|
||||
style={{ background: 'transparent', color: 'var(--gm-text)', whiteSpace: 'nowrap', border: 'none' }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-row-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<Mic className="size-3" />
|
||||
Take notes only
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export function RichMarkdownViewer({ content }: { content: string }) {
|
|||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ interface SearchResult {
|
|||
path: string
|
||||
}
|
||||
|
||||
export type SearchType = 'knowledge' | 'chat'
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
|
|
@ -46,9 +46,6 @@ interface CommandPaletteProps {
|
|||
onOpenChange: (open: boolean) => void
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
// Overrides the sidebar-section default for the initial scope (e.g. the
|
||||
// knowledge view opens search scoped to knowledge).
|
||||
defaultScope?: SearchType
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
|
|
@ -56,7 +53,6 @@ export function CommandPalette({
|
|||
onOpenChange,
|
||||
onSelectFile,
|
||||
onSelectRun,
|
||||
defaultScope,
|
||||
}: CommandPaletteProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
|
@ -65,7 +61,7 @@ export function CommandPalette({
|
|||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||
() => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))
|
||||
() => new Set(activeTabToTypes(activeSection))
|
||||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
|
|
@ -73,9 +69,9 @@ export function CommandPalette({
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)))
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection, defaultScope])
|
||||
}, [open, activeSection])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,7 +11,6 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -25,9 +24,8 @@ import { useTheme } from "@/contexts/theme-context"
|
|||
import { toast } from "sonner"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -45,10 +43,10 @@ const tabs: TabConfig[] = [
|
|||
description: "Manage your Rowboat account",
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections",
|
||||
id: "connected-accounts",
|
||||
label: "Connected Accounts",
|
||||
icon: Plug,
|
||||
description: "Manage accounts and tools",
|
||||
description: "Manage connected services",
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
|
|
@ -71,18 +69,18 @@ const tabs: TabConfig[] = [
|
|||
path: "config/security.json",
|
||||
description: "Configure allowed shell commands",
|
||||
},
|
||||
{
|
||||
id: "code-mode",
|
||||
label: "Code Mode",
|
||||
icon: Terminal,
|
||||
description: "Delegate coding tasks to Claude Code or Codex",
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools Library",
|
||||
icon: Wrench,
|
||||
description: "Browse and enable toolkits",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
|
|
@ -90,93 +88,10 @@ const tabs: TabConfig[] = [
|
|||
path: "config/tags.json",
|
||||
description: "Configure tags for notes and emails",
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
icon: HelpCircle,
|
||||
description: "Get help and support",
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
/** Optional trigger element. Omit when controlling `open` externally. */
|
||||
children?: React.ReactNode
|
||||
/** Tab to open on when the dialog is shown. Defaults to "account". */
|
||||
defaultTab?: ConfigTab
|
||||
/** Controlled open state. When provided, the dialog is fully controlled. */
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
// --- Help & Support tab ---
|
||||
|
||||
function HelpSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Help & Support</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
|
||||
<Bug className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Report a bug</span>
|
||||
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
|
||||
<MessageCircle className="size-4 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Join our Discord</span>
|
||||
<span className="text-xs text-muted-foreground">Chat with the community</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Contact us</span>
|
||||
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
|
||||
</div>
|
||||
</Button>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<a
|
||||
href="https://www.rowboatlabs.com/terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<span>·</span>
|
||||
<a
|
||||
href="https://www.rowboatlabs.com/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// --- Theme option for Appearance tab ---
|
||||
|
|
@ -211,7 +126,7 @@ function ThemeOption({
|
|||
}
|
||||
|
||||
function AppearanceSettings() {
|
||||
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -241,50 +156,6 @@ function AppearanceSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Chat</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose where chat sits when another pane is open
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ThemeOption
|
||||
label="Chat right"
|
||||
icon={PanelRight}
|
||||
isSelected={chatPanePlacement === "right"}
|
||||
onClick={() => setChatPanePlacement("right")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat middle"
|
||||
icon={MessageCircle}
|
||||
isSelected={chatPanePlacement === "middle"}
|
||||
onClick={() => setChatPanePlacement("middle")}
|
||||
/>
|
||||
</div>
|
||||
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose how much width chat gets when another pane is open
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ThemeOption
|
||||
label="Chat smaller"
|
||||
icon={MessageCircle}
|
||||
isSelected={chatPaneSize === "chat-smaller"}
|
||||
onClick={() => setChatPaneSize("chat-smaller")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat equal"
|
||||
icon={Monitor}
|
||||
isSelected={chatPaneSize === "chat-equal"}
|
||||
onClick={() => setChatPaneSize("chat-equal")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat bigger"
|
||||
icon={PanelRight}
|
||||
isSelected={chatPaneSize === "chat-bigger"}
|
||||
onClick={() => setChatPaneSize("chat-bigger")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -322,27 +193,17 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
|||
"openai-compatible": "http://localhost:1234/v1",
|
||||
}
|
||||
|
||||
type ProviderModelConfig = {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
models: string[]
|
||||
knowledgeGraphModel: string
|
||||
meetingNotesModel: string
|
||||
liveNoteAgentModel: string
|
||||
autoPermissionDecisionModel: string
|
||||
}
|
||||
|
||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
|
|
@ -368,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
|
|
@ -443,7 +304,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||
meetingNotesModel: e.meetingNotesModel || "",
|
||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -462,7 +322,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
|
|
@ -538,7 +397,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -573,7 +431,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
})
|
||||
setDefaultProvider(prov)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -605,7 +462,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
|
|
@ -613,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
})
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -871,40 +727,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-permission model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.autoPermissionDecisionModel}
|
||||
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
@ -1748,255 +1570,11 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Code Mode Settings ---
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
function AgentStatusRow({
|
||||
name,
|
||||
installLink,
|
||||
signInCommand,
|
||||
status,
|
||||
}: {
|
||||
name: string
|
||||
installLink: string
|
||||
signInCommand: string
|
||||
status: AgentStatus | null
|
||||
}) {
|
||||
const ready = status?.installed && status?.signedIn
|
||||
const needsSignInOnly = status?.installed && !status?.signedIn
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
||||
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Installed
|
||||
</span>
|
||||
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Signed in
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{ready ? (
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Ready
|
||||
</span>
|
||||
) : needsSignInOnly ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={installLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
Install & sign in
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(false)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
setStatusLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||
setStatus(result)
|
||||
} catch {
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setStatusLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
||||
if (!cancelled) {
|
||||
setEnabled(result.enabled)
|
||||
setApprovalPolicy(result.approvalPolicy ?? 'ask')
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setEnabled(false)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
loadStatus()
|
||||
return () => { cancelled = true }
|
||||
}, [dialogOpen, loadStatus])
|
||||
|
||||
const handleToggle = useCallback(async (next: boolean) => {
|
||||
setSaving(true)
|
||||
setEnabled(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
||||
} catch {
|
||||
setEnabled(!next)
|
||||
toast.error("Failed to update code mode")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [approvalPolicy])
|
||||
|
||||
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
|
||||
const prev = approvalPolicy
|
||||
setSaving(true)
|
||||
setApprovalPolicy(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
} catch {
|
||||
setApprovalPolicy(prev)
|
||||
toast.error("Failed to update approval policy")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [enabled, approvalPolicy])
|
||||
|
||||
const anyReady = status?.claude.installed && status?.claude.signedIn
|
||||
|| status?.codex.installed && status?.codex.signedIn
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
||||
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
||||
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
|
||||
and streams its work — tool calls, file diffs, and approvals — back into chat.
|
||||
</p>
|
||||
<p>
|
||||
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
||||
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
|
||||
<button
|
||||
onClick={() => { void loadStatus() }}
|
||||
disabled={statusLoading}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
Re-check
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<AgentStatusRow
|
||||
name="Claude Code"
|
||||
installLink="https://claude.ai/code"
|
||||
signInCommand="claude login"
|
||||
status={status?.claude ?? null}
|
||||
/>
|
||||
<AgentStatusRow
|
||||
name="Codex"
|
||||
installLink="https://developers.openai.com/codex/cli"
|
||||
signInCommand="codex login"
|
||||
status={status?.codex ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Enable code mode</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="rounded-md border px-3 py-3 space-y-2">
|
||||
<div className="text-sm font-medium">Approvals</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
How the coding agent checks in before changing files or running commands. You always see
|
||||
everything it does in the timeline — this only controls the prompts.
|
||||
</div>
|
||||
<Select
|
||||
value={approvalPolicy}
|
||||
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ask">Ask every time</SelectItem>
|
||||
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
|
||||
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
|
||||
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
|
||||
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && status && !anyReady && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-amber-900 dark:text-amber-200">
|
||||
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
|
||||
account, then click Re-check.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = controlledOpen ?? internalOpen
|
||||
const setOpen = useCallback((next: boolean) => {
|
||||
if (onOpenChange) onOpenChange(next)
|
||||
else setInternalOpen(next)
|
||||
}, [onOpenChange])
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
|
||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -2004,11 +1582,6 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [rowboatConnected, setRowboatConnected] = useState(false)
|
||||
|
||||
// Reset to the requested default tab each time the dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab)
|
||||
}, [open, defaultTab])
|
||||
|
||||
// Check if user is signed in to Rowboat
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -2034,7 +1607,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -2100,7 +1673,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
||||
>
|
||||
|
|
@ -2142,21 +1715,11 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Primary accounts</h4>
|
||||
<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 === "connected-accounts" ? (
|
||||
<ConnectedAccountsSettings dialogOpen={open} />
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? <RowboatModelSettings dialogOpen={open} />
|
||||
|
|
@ -2165,10 +1728,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
<CodeModeSettings dialogOpen={open} />
|
||||
) : activeTab === "tools" ? (
|
||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -17,44 +17,11 @@ import {
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "sonner"
|
||||
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
interface AccountSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
function formatPlanName(plan: string | null | undefined) {
|
||||
if (!plan) return 'No Plan'
|
||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
|
||||
}
|
||||
|
||||
function CreditUsageBar({ label, bucket, helper }: {
|
||||
label: string
|
||||
bucket: BillingUsageBucket
|
||||
helper?: string
|
||||
}) {
|
||||
const pct = bucket.sanctionedCredits > 0
|
||||
? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100)))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
{helper ? <p className="text-[11px] text-muted-foreground">{helper}</p> : null}
|
||||
</div>
|
||||
<p className="shrink-0 text-xs font-medium tabular-nums">
|
||||
{pct}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [connectionLoading, setConnectionLoading] = useState(true)
|
||||
|
|
@ -197,7 +164,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{formatPlanName(billing.subscriptionPlan)}
|
||||
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
|
||||
</p>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
|
|
@ -212,19 +179,14 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
{!billing.subscriptionPlan && (
|
||||
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
|
||||
)}
|
||||
{billing.subscriptionPlan === 'free' && (
|
||||
<p className="text-xs text-muted-foreground">Free usage resets daily at 00:00 UTC.</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<CreditUsageBar label="Plan usage" bucket={billing.monthly} />
|
||||
<CreditUsageBar
|
||||
label="Daily use"
|
||||
bucket={billing.daily}
|
||||
helper="Daily usage resets at 00:00 UTC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
|
@ -119,15 +119,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
{/* Email & Calendar Section */}
|
||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Email & Calendar
|
||||
</span>
|
||||
</div>
|
||||
{c.useComposioForGoogle ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
|
@ -174,9 +174,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||
)}
|
||||
{c.useComposioForGoogleCalendar && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Calendar className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
|
@ -220,14 +220,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
<Separator className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes Section */}
|
||||
{c.providers.includes('fireflies-ai') && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,576 +0,0 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
File as FileIcon,
|
||||
FilePlus,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
type WorkspaceActions = {
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
tree: TreeNode[]
|
||||
initialPath?: string | null
|
||||
actions: WorkspaceActions
|
||||
// Folder currently being browsed. Controlled by the app so drill-down
|
||||
// participates in the global back/forward history.
|
||||
onNavigate: (path: string) => void
|
||||
onOpenNote: (path: string) => void
|
||||
onCreateWorkspace: (name: string) => Promise<void>
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('mac')) return 'Finder'
|
||||
if (platform.includes('win')) return 'Explorer'
|
||||
return 'File Manager'
|
||||
}
|
||||
|
||||
function fileExtensionLabel(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot <= 0 || dot === name.length - 1) return 'File'
|
||||
return `${name.slice(dot + 1).toUpperCase()} file`
|
||||
}
|
||||
|
||||
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
|
||||
if (!nodes) return null
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return node
|
||||
if (node.kind === 'dir' && path.startsWith(`${node.path}/`)) {
|
||||
const found = findNode(node.children, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function countChildren(node: TreeNode | null): number {
|
||||
if (!node || node.kind !== 'dir' || !node.children) return 0
|
||||
return node.children.length
|
||||
}
|
||||
|
||||
async function uniqueChildPath(parent: string, name: string): Promise<string> {
|
||||
const dot = name.lastIndexOf('.')
|
||||
const base = dot > 0 ? name.slice(0, dot) : name
|
||||
const ext = dot > 0 ? name.slice(dot) : ''
|
||||
let candidate = `${parent}/${name}`
|
||||
let i = 1
|
||||
while ((await window.ipc.invoke('workspace:exists', { path: candidate })).exists) {
|
||||
candidate = `${parent}/${base} (${i})${ext}`
|
||||
i += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
resolve(result.split(',')[1] ?? '')
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
||||
const currentPath = initialPath || WORKSPACE_ROOT
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const dragDepthRef = useRef(0)
|
||||
const filesInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const isRoot = currentPath === WORKSPACE_ROOT
|
||||
const fileManagerName = getFileManagerName()
|
||||
|
||||
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
|
||||
|
||||
const items = useMemo<TreeNode[]>(() => {
|
||||
const children = currentNode?.children ?? []
|
||||
const filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}, [currentNode, isRoot])
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (isRoot) return [] as { path: string; name: string }[]
|
||||
const rel = currentPath.slice(WORKSPACE_ROOT.length + 1)
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
let acc = WORKSPACE_ROOT
|
||||
return parts.map((seg) => {
|
||||
acc = `${acc}/${seg}`
|
||||
return { path: acc, name: seg }
|
||||
})
|
||||
}, [currentPath, isRoot])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: TreeNode) => {
|
||||
if (renameTarget) return
|
||||
if (item.kind === 'dir') {
|
||||
onNavigate(item.path)
|
||||
} else {
|
||||
onOpenNote(item.path)
|
||||
}
|
||||
},
|
||||
[onNavigate, onOpenNote, renameTarget],
|
||||
)
|
||||
|
||||
const beginRename = useCallback((item: TreeNode) => {
|
||||
setRenameTarget(item.path)
|
||||
setRenameValue(item.name)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(async () => {
|
||||
if (!renameTarget) return
|
||||
const node = items.find((i) => i.path === renameTarget)
|
||||
const trimmed = renameValue.trim()
|
||||
setRenameTarget(null)
|
||||
if (!node || !trimmed || trimmed === node.name || trimmed.includes('/')) return
|
||||
const parent = renameTarget.slice(0, renameTarget.lastIndexOf('/'))
|
||||
try {
|
||||
await window.ipc.invoke('workspace:rename', { from: renameTarget, to: `${parent}/${trimmed}` })
|
||||
toast('Renamed', 'success')
|
||||
} catch {
|
||||
toast('Failed to rename', 'error')
|
||||
}
|
||||
}, [renameTarget, renameValue, items])
|
||||
|
||||
const handleDelete = useCallback(async (item: TreeNode) => {
|
||||
try {
|
||||
await actions.remove(item.path)
|
||||
toast('Moved to trash', 'success')
|
||||
} catch {
|
||||
toast('Failed to delete', 'error')
|
||||
}
|
||||
}, [actions])
|
||||
|
||||
const uploadFiles = useCallback(async (files: FileList | File[], preserveStructure = false) => {
|
||||
const list = Array.from(files)
|
||||
if (list.length === 0) return
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const file of list) {
|
||||
const data = await readFileAsBase64(file)
|
||||
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath
|
||||
const target = preserveStructure && rel
|
||||
? `${currentPath}/${rel}`
|
||||
: await uniqueChildPath(currentPath, file.name)
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: target,
|
||||
data,
|
||||
opts: { encoding: 'base64', mkdirp: true },
|
||||
})
|
||||
}
|
||||
toast(list.length === 1 ? 'Added' : `${list.length} items added`, 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to add files:', err)
|
||||
toast('Failed to add', 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [currentPath])
|
||||
|
||||
// Drag-and-drop (only inside a workspace folder, not at the root grid).
|
||||
// stopPropagation keeps the drop from also reaching the copilot's
|
||||
// document-level drop listener when it lands on the workspace area.
|
||||
const dropEnabled = !isRoot
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!dropEnabled) return
|
||||
if (!Array.from(e.dataTransfer.types).includes('Files')) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current += 1
|
||||
setIsDraggingOver(true)
|
||||
}, [dropEnabled])
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!dropEnabled) return
|
||||
if (!Array.from(e.dataTransfer.types).includes('Files')) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}, [dropEnabled])
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!dropEnabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current -= 1
|
||||
if (dragDepthRef.current <= 0) {
|
||||
dragDepthRef.current = 0
|
||||
setIsDraggingOver(false)
|
||||
}
|
||||
}, [dropEnabled])
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!dropEnabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current = 0
|
||||
setIsDraggingOver(false)
|
||||
if (e.dataTransfer.files?.length) void uploadFiles(e.dataTransfer.files)
|
||||
}, [dropEnabled, uploadFiles])
|
||||
|
||||
const resetAddDialog = useCallback(() => {
|
||||
setNewName('')
|
||||
setError(null)
|
||||
setCreating(false)
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed) {
|
||||
setError('Name is required')
|
||||
return
|
||||
}
|
||||
if (trimmed.includes('/')) {
|
||||
setError('Name cannot contain "/"')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCreateWorkspace(trimmed)
|
||||
setAddOpen(false)
|
||||
resetAddDialog()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
||||
setCreating(false)
|
||||
}
|
||||
}, [newName, onCreateWorkspace, resetAddDialog])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex min-w-0 items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(WORKSPACE_ROOT)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
|
||||
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
<span className="font-medium">Workspace</span>
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, idx) => {
|
||||
const isLast = idx === breadcrumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.path} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-4 text-muted-foreground/60" />
|
||||
{isLast ? (
|
||||
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
|
||||
{crumb.name}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(crumb.path)}
|
||||
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => actions.revealInFileManager(currentPath, true)}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
Open in {fileManagerName}
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add workspace
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="w-full">
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
Add files…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
Add folder…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={filesInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) void uploadFiles(e.target.files, false)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error non-standard but supported in Chromium/Electron
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) void uploadFiles(e.target.files, true)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto px-6 py-6"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
|
||||
<FolderIcon className="size-10 opacity-50" />
|
||||
<div className="text-sm">
|
||||
{isRoot
|
||||
? 'No workspaces yet. Create one to get started.'
|
||||
: 'This folder is empty. Drag files in or use New note / New folder.'}
|
||||
</div>
|
||||
{isRoot && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add workspace
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||
{items.map((item) => {
|
||||
const childCount = item.kind === 'dir' ? countChildren(item) : 0
|
||||
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
|
||||
const isRenaming = renameTarget === item.path
|
||||
const card = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
className="group flex w-full flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
|
||||
>
|
||||
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
|
||||
<div className="min-w-0 w-full">
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') { e.preventDefault(); void commitRename() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); setRenameTarget(null) }
|
||||
}}
|
||||
className="h-6 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{item.name}</div>
|
||||
)}
|
||||
{!isRenaming && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.kind === 'dir'
|
||||
? `${childCount} ${childCount === 1 ? 'item' : 'items'}`
|
||||
: fileExtensionLabel(item.name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
const isDir = item.kind === 'dir'
|
||||
return (
|
||||
<ContextMenu key={item.path}>
|
||||
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
{isDir && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.createNote(item.path)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void actions.createFolder(item.path)}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{!isDir && actions.onOpenInNewTab && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {fileManagerName}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => beginRename(item)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropEnabled && isDraggingOver && (
|
||||
<div className="pointer-events-none absolute inset-3 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5 text-primary">
|
||||
<UploadCloud className="size-8" />
|
||||
<span className="text-sm font-medium">Drop files to add to this folder</span>
|
||||
</div>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-md bg-foreground/80 px-3 py-1.5 text-xs text-background">
|
||||
Adding files…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={addOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAddOpen(open)
|
||||
if (!open) resetAddDialog()
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Workspaces are top-level folders inside knowledge/Workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="workspace-name" className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. Alpha"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !creating) {
|
||||
e.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setAddOpen(false)
|
||||
resetAddDialog()
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleCreate()} disabled={creating || !newName.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,32 +3,16 @@
|
|||
import * as React from "react"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
export type ChatPanePlacement = "right" | "middle"
|
||||
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
|
||||
|
||||
type ThemeContextProps = {
|
||||
theme: Theme
|
||||
resolvedTheme: "light" | "dark"
|
||||
setTheme: (theme: Theme) => void
|
||||
chatPanePlacement: ChatPanePlacement
|
||||
setChatPanePlacement: (placement: ChatPanePlacement) => void
|
||||
chatPaneSize: ChatPaneSize
|
||||
setChatPaneSize: (size: ChatPaneSize) => void
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
||||
|
||||
const STORAGE_KEY = "rowboat-theme"
|
||||
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
|
||||
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
|
||||
|
||||
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
|
||||
return value === "right" || value === "middle"
|
||||
}
|
||||
|
||||
function isChatPaneSize(value: string | null): value is ChatPaneSize {
|
||||
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
|
||||
}
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light"
|
||||
|
|
@ -55,16 +39,6 @@ export function ThemeProvider({
|
|||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||
return stored || defaultTheme
|
||||
})
|
||||
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
|
||||
if (typeof window === "undefined") return "right"
|
||||
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
|
||||
return isChatPanePlacement(stored) ? stored : "right"
|
||||
})
|
||||
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
|
||||
if (typeof window === "undefined") return "chat-smaller"
|
||||
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
|
||||
return isChatPaneSize(stored) ? stored : "chat-smaller"
|
||||
})
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||
if (theme === "system") return getSystemTheme()
|
||||
|
|
@ -102,27 +76,13 @@ export function ThemeProvider({
|
|||
setThemeState(newTheme)
|
||||
}, [])
|
||||
|
||||
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
|
||||
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
|
||||
setChatPanePlacementState(placement)
|
||||
}, [])
|
||||
|
||||
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
|
||||
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
|
||||
setChatPaneSizeState(size)
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo<ThemeContextProps>(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
chatPanePlacement,
|
||||
setChatPanePlacement,
|
||||
chatPaneSize,
|
||||
setChatPaneSize,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
|
||||
[theme, resolvedTheme, setTheme]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||
|
||||
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
|
||||
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
|
||||
|
|
@ -25,12 +25,9 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
|||
for (const match of matches) {
|
||||
const matchIndex = match.index ?? 0
|
||||
const matchText = match[0] ?? ''
|
||||
const rawLink = match[1]?.trim() ?? ''
|
||||
const { label } = splitWikiAlias(rawLink)
|
||||
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
|
||||
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
|
||||
const isHeadingOnlyLink = !basePath && Boolean(heading)
|
||||
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
|
||||
const rawPath = match[1]?.trim() ?? ''
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
|
||||
|
||||
if (matchIndex > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
|
||||
|
|
@ -38,8 +35,7 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
|||
|
||||
if (isValidPath) {
|
||||
const el = document.createElement('wiki-link')
|
||||
el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
|
||||
if (label) el.setAttribute('data-label', label)
|
||||
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
|
||||
fragment.appendChild(el)
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(matchText))
|
||||
|
|
@ -84,9 +80,6 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
path: {
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -96,34 +89,28 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
tag: 'wiki-link[data-path]',
|
||||
getAttrs: (element: Element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
label: (element as HTMLElement).getAttribute('data-label'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a[data-type="wiki-link"]',
|
||||
getAttrs: (element: Element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
label: (element as HTMLElement).getAttribute('data-label'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
|
||||
const label = wikiLabel(node.attrs.path) || node.attrs.path
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
'data-type': 'wiki-link',
|
||||
'data-path': node.attrs.path,
|
||||
'href': '#',
|
||||
'class': 'wiki-link',
|
||||
'aria-label': node.attrs.path,
|
||||
},
|
||||
node.attrs.label ? { 'data-label': node.attrs.label } : {}
|
||||
),
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'wiki-link',
|
||||
'data-path': node.attrs.path,
|
||||
'href': '#',
|
||||
'class': 'wiki-link',
|
||||
'aria-label': node.attrs.path,
|
||||
}),
|
||||
label,
|
||||
]
|
||||
},
|
||||
|
|
@ -133,8 +120,7 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
markdown: {
|
||||
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
|
||||
const path = node.attrs.path ?? ''
|
||||
const label = (node.attrs as { label?: string }).label
|
||||
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
|
||||
state.write(`[[${path}]]`)
|
||||
},
|
||||
parse: {
|
||||
updateDOM(element: HTMLElement) {
|
||||
|
|
@ -151,20 +137,14 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
new InputRule({
|
||||
find: wikiLinkInputRegex,
|
||||
handler: ({ state, range, match }) => {
|
||||
const rawLink = match[1]?.trim()
|
||||
const { label } = splitWikiAlias(rawLink ?? '')
|
||||
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
|
||||
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
|
||||
const isHeadingOnlyLink = !basePath && Boolean(heading)
|
||||
if (
|
||||
!normalizedPath
|
||||
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
|
||||
) return null
|
||||
const rawPath = match[1]?.trim()
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||
if (state.selection.$from.parent.type.spec.code) return null
|
||||
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
||||
|
||||
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
|
||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
|
||||
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath }))
|
||||
onCreate?.(finalPath)
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
|
||||
|
||||
/**
|
||||
* Identifies the user in PostHog when signed into Rowboat,
|
||||
|
|
@ -18,7 +17,7 @@ export function useAnalyticsIdentity() {
|
|||
// Identify if Rowboat account is connected
|
||||
const rowboat = config.rowboat
|
||||
if (rowboat?.connected && rowboat?.userId) {
|
||||
identifyUser(rowboat.userId)
|
||||
posthog.identify(rowboat.userId)
|
||||
}
|
||||
|
||||
// Set provider connection flags
|
||||
|
|
@ -70,7 +69,7 @@ export function useAnalyticsIdentity() {
|
|||
// Rowboat sign-in
|
||||
if (event.success) {
|
||||
if (event.userId) {
|
||||
identifyUser(event.userId)
|
||||
posthog.identify(event.userId)
|
||||
}
|
||||
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
||||
posthog.capture('user_signed_in')
|
||||
|
|
@ -81,7 +80,7 @@ export function useAnalyticsIdentity() {
|
|||
// 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()
|
||||
posthog.reset()
|
||||
})
|
||||
|
||||
return cleanup
|
||||
|
|
|
|||
|
|
@ -1,42 +1,5 @@
|
|||
import posthog from 'posthog-js'
|
||||
|
||||
let appVersion: string | undefined
|
||||
let apiUrl: string | undefined
|
||||
|
||||
function appVersionProperties(): Record<string, string> {
|
||||
return appVersion ? { app_version: appVersion } : {}
|
||||
}
|
||||
|
||||
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
|
||||
appVersion = props.appVersion?.trim() || undefined
|
||||
apiUrl = props.apiUrl?.trim() || undefined
|
||||
|
||||
const eventProperties = appVersionProperties()
|
||||
if (Object.keys(eventProperties).length > 0) {
|
||||
posthog.register(eventProperties)
|
||||
}
|
||||
|
||||
const personProperties = {
|
||||
...(apiUrl ? { api_url: apiUrl } : {}),
|
||||
...eventProperties,
|
||||
}
|
||||
if (Object.keys(personProperties).length > 0) {
|
||||
posthog.people.set(personProperties)
|
||||
}
|
||||
}
|
||||
|
||||
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||
posthog.identify(userId, {
|
||||
...properties,
|
||||
...appVersionProperties(),
|
||||
})
|
||||
}
|
||||
|
||||
export function resetAnalyticsIdentity() {
|
||||
posthog.reset()
|
||||
configureAnalyticsContext({ appVersion, apiUrl })
|
||||
}
|
||||
|
||||
export function chatSessionCreated(runId: string) {
|
||||
posthog.capture('chat_session_created', { run_id: runId })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
export const BILLING_ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: "You've run out of credits",
|
||||
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
pattern: /subscription not active/i,
|
||||
title: 'Your subscription is inactive',
|
||||
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||
cta: 'Reactivate',
|
||||
},
|
||||
] as const
|
||||
|
||||
export type BillingErrorMatch = (typeof BILLING_ERROR_PATTERNS)[number]
|
||||
|
||||
export function matchBillingError(message: string): BillingErrorMatch | null {
|
||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||
}
|
||||
|
|
@ -1,23 +1,7 @@
|
|||
/**
|
||||
* 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).
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to a top-level conferenceLink if present.
|
||||
*/
|
||||
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
|
|
@ -27,5 +11,5 @@ export function extractConferenceLink(raw: Record<string, unknown>): string | un
|
|||
}
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return findMeetingUrl(raw.location) ?? findMeetingUrl(raw.description)
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type { ToolUIPart } from 'ai'
|
||||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
|
||||
|
||||
export interface MessageAttachment {
|
||||
path: string
|
||||
|
|
@ -28,9 +27,6 @@ export interface ToolCall {
|
|||
streamingOutput?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'error'
|
||||
timestamp: number
|
||||
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
|
||||
codeRunEvents?: CodeRunEvent[]
|
||||
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
|
|
@ -50,7 +46,6 @@ export type ChatTabViewState = {
|
|||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||
permissionResponses: Map<string, PermissionResponse>
|
||||
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
|
||||
}
|
||||
|
||||
export type ChatViewportAnchorState = {
|
||||
|
|
@ -65,7 +60,6 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
|||
pendingAskHumanRequests: new Map(),
|
||||
allPermissionRequests: new Map(),
|
||||
permissionResponses: new Map(),
|
||||
autoPermissionDecisions: new Map(),
|
||||
})
|
||||
|
||||
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
||||
|
|
@ -485,19 +479,19 @@ export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardD
|
|||
|
||||
// Human-friendly display names for builtin tools
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'file-readText': 'Reading file',
|
||||
'file-writeText': 'Writing file',
|
||||
'file-editText': 'Editing file',
|
||||
'file-list': 'Reading directory',
|
||||
'file-exists': 'Checking path',
|
||||
'file-stat': 'Getting file info',
|
||||
'file-glob': 'Finding files',
|
||||
'file-grep': 'Searching files',
|
||||
'file-mkdir': 'Creating directory',
|
||||
'file-rename': 'Renaming',
|
||||
'file-copy': 'Copying file',
|
||||
'file-remove': 'Removing',
|
||||
'file-getRoot': 'Getting file root',
|
||||
'workspace-readFile': 'Reading file',
|
||||
'workspace-writeFile': 'Writing file',
|
||||
'workspace-edit': 'Editing file',
|
||||
'workspace-readdir': 'Reading directory',
|
||||
'workspace-exists': 'Checking path',
|
||||
'workspace-stat': 'Getting file info',
|
||||
'workspace-glob': 'Finding files',
|
||||
'workspace-grep': 'Searching files',
|
||||
'workspace-mkdir': 'Creating directory',
|
||||
'workspace-rename': 'Renaming',
|
||||
'workspace-copy': 'Copying file',
|
||||
'workspace-remove': 'Removing',
|
||||
'workspace-getRoot': 'Getting workspace root',
|
||||
'loadSkill': 'Loading skill',
|
||||
'parseFile': 'Parsing file',
|
||||
'LLMParse': 'Extracting content',
|
||||
|
|
@ -606,7 +600,6 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
|||
|
||||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||
if (!isToolCall(item)) return false
|
||||
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
|
||||
if (getWebSearchCardData(item)) return false
|
||||
if (getComposioConnectCardData(item)) return false
|
||||
if (getAppActionCardData(item)) return false
|
||||
|
|
@ -660,63 +653,6 @@ export const getToolGroupSummary = (tools: ToolCall[]): string => {
|
|||
return names.join(' · ')
|
||||
}
|
||||
|
||||
// Past-tense action phrases for summarizing a finished tool group, e.g.
|
||||
// "read 3 files, listed directory". Keyed by builtin tool name.
|
||||
const TOOL_ACTION_VERBS: Record<string, { verb: string; one: string; many: string }> = {
|
||||
'file-readText': { verb: 'read', one: 'file', many: 'files' },
|
||||
'file-writeText': { verb: 'wrote', one: 'file', many: 'files' },
|
||||
'file-editText': { verb: 'edited', one: 'file', many: 'files' },
|
||||
'file-list': { verb: 'listed', one: 'directory', many: 'directories' },
|
||||
'file-exists': { verb: 'checked', one: 'path', many: 'paths' },
|
||||
'file-stat': { verb: 'inspected', one: 'file', many: 'files' },
|
||||
'file-glob': { verb: 'searched for', one: 'file', many: 'files' },
|
||||
'file-grep': { verb: 'searched', one: 'file', many: 'files' },
|
||||
'file-mkdir': { verb: 'created', one: 'directory', many: 'directories' },
|
||||
'file-rename': { verb: 'renamed', one: 'file', many: 'files' },
|
||||
'file-copy': { verb: 'copied', one: 'file', many: 'files' },
|
||||
'file-remove': { verb: 'removed', one: 'file', many: 'files' },
|
||||
'file-getRoot': { verb: 'resolved', one: 'file root', many: 'file roots' },
|
||||
'executeCommand': { verb: 'ran', one: 'command', many: 'commands' },
|
||||
'executeMcpTool': { verb: 'ran', one: 'MCP tool', many: 'MCP tools' },
|
||||
'listMcpServers': { verb: 'listed', one: 'MCP server', many: 'MCP servers' },
|
||||
'listMcpTools': { verb: 'listed', one: 'MCP tool', many: 'MCP tools' },
|
||||
'save-to-memory': { verb: 'saved', one: 'memory', many: 'memories' },
|
||||
'loadSkill': { verb: 'loaded', one: 'skill', many: 'skills' },
|
||||
'parseFile': { verb: 'parsed', one: 'file', many: 'files' },
|
||||
}
|
||||
|
||||
// Summarize what a group of tools actually did, grouping identical actions
|
||||
// and counting them: "read 3 files, listed directory". Unmapped tools fall
|
||||
// back to their lowercased display name.
|
||||
export const getToolActionsSummary = (tools: ToolCall[]): string => {
|
||||
const order: string[] = []
|
||||
const grouped = new Map<string, { phrase: typeof TOOL_ACTION_VERBS[string] | null; count: number; fallback: string }>()
|
||||
for (const tool of tools) {
|
||||
const phrase = TOOL_ACTION_VERBS[tool.name] ?? null
|
||||
const key = phrase ? `${phrase.verb}|${phrase.one}` : tool.name
|
||||
const existing = grouped.get(key)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
grouped.set(key, { phrase, count: 1, fallback: getToolDisplayName(tool) })
|
||||
order.push(key)
|
||||
}
|
||||
}
|
||||
const phrases = order.map((key) => {
|
||||
const { phrase, count, fallback } = grouped.get(key)!
|
||||
if (!phrase) return fallback.toLowerCase()
|
||||
if (count > 1) return `${phrase.verb} ${count} ${phrase.many}`
|
||||
const article = /^[aeiou]/i.test(phrase.one) ? 'an' : 'a'
|
||||
return `${phrase.verb} ${article} ${phrase.one}`
|
||||
})
|
||||
// Show at most two operations; collapse the rest into "more...".
|
||||
const MAX_ACTIONS = 2
|
||||
if (phrases.length > MAX_ACTIONS) {
|
||||
return `${phrases.slice(0, MAX_ACTIONS).join(', ')}, more...`
|
||||
}
|
||||
return phrases.join(', ')
|
||||
}
|
||||
|
||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* also uses it to decide what to keep mounted.
|
||||
*/
|
||||
|
||||
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' | 'docx'
|
||||
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf'
|
||||
|
||||
const VIEWER_BY_EXT: Record<string, ViewerType> = {
|
||||
html: 'html',
|
||||
|
|
@ -31,7 +31,6 @@ const VIEWER_BY_EXT: Record<string, ViewerType> = {
|
|||
flac: 'audio',
|
||||
aac: 'audio',
|
||||
pdf: 'pdf',
|
||||
docx: 'docx',
|
||||
}
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
|
|
|
|||
|
|
@ -3,50 +3,24 @@ const KNOWLEDGE_PREFIX = 'knowledge/'
|
|||
export const stripKnowledgePrefix = (path: string) =>
|
||||
path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path
|
||||
|
||||
export const splitWikiAlias = (input: string) => {
|
||||
const separatorIndex = input.indexOf('|')
|
||||
if (separatorIndex === -1) return { target: input, label: undefined }
|
||||
const target = input.slice(0, separatorIndex)
|
||||
const label = input.slice(separatorIndex + 1).trim()
|
||||
return { target, label: label || undefined }
|
||||
}
|
||||
|
||||
export const splitWikiFragment = (path: string) => {
|
||||
const hashIndex = path.indexOf('#')
|
||||
if (hashIndex === -1) return { path: path, heading: undefined }
|
||||
const basePath = path.slice(0, hashIndex)
|
||||
const heading = path.slice(hashIndex + 1).trim()
|
||||
return { path: basePath, heading: heading || undefined }
|
||||
}
|
||||
|
||||
export const normalizeWikiPath = (input: string) => {
|
||||
const { target } = splitWikiAlias(input)
|
||||
const trimmed = target.trim().replace(/^\/+/, '').replace(/^\.\//, '')
|
||||
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '')
|
||||
return stripKnowledgePrefix(trimmed)
|
||||
}
|
||||
|
||||
export const ensureMarkdownExtension = (path: string) => {
|
||||
const { path: basePath, heading } = splitWikiFragment(path)
|
||||
if (!basePath) return heading ? `#${heading}` : path
|
||||
const filePath = basePath.toLowerCase().endsWith('.md') ? basePath : `${basePath}.md`
|
||||
return heading ? `${filePath}#${heading}` : filePath
|
||||
if (path.toLowerCase().endsWith('.md')) return path
|
||||
return `${path}.md`
|
||||
}
|
||||
|
||||
export const toKnowledgePath = (wikiPath: string) => {
|
||||
const normalized = normalizeWikiPath(wikiPath)
|
||||
const { path: basePath } = splitWikiFragment(normalized)
|
||||
if (!basePath || basePath.includes('..') || basePath.endsWith('/')) return null
|
||||
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(basePath)}`
|
||||
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null
|
||||
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}`
|
||||
}
|
||||
|
||||
export const wikiLabel = (wikiPath: string) => {
|
||||
const { label } = splitWikiAlias(wikiPath)
|
||||
if (label) return label
|
||||
|
||||
const normalized = normalizeWikiPath(wikiPath)
|
||||
const { path: basePath, heading } = splitWikiFragment(normalized)
|
||||
if (!basePath && heading) return heading
|
||||
|
||||
const name = (basePath || normalized).split('/').pop() || normalized
|
||||
const name = normalized.split('/').pop() || normalized
|
||||
return name.replace(/\.md$/i, '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import posthog from 'posthog-js'
|
||||
import { PostHogProvider } from 'posthog-js/react'
|
||||
import type { CaptureResult } from 'posthog-js'
|
||||
import { ThemeProvider } from '@/contexts/theme-context'
|
||||
import { configureAnalyticsContext } from './lib/analytics'
|
||||
|
||||
// Fetch the stable installation ID from main so renderer + main share one
|
||||
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
||||
|
|
@ -13,36 +12,19 @@ import { configureAnalyticsContext } from './lib/analytics'
|
|||
async function bootstrap() {
|
||||
let installationId: string | undefined
|
||||
let apiUrl: string | undefined
|
||||
let appVersion: string | undefined
|
||||
try {
|
||||
const result = await window.ipc.invoke('analytics:bootstrap', null)
|
||||
installationId = result.installationId
|
||||
apiUrl = result.apiUrl
|
||||
appVersion = result.appVersion
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to bootstrap from main:', err)
|
||||
}
|
||||
|
||||
configureAnalyticsContext({ apiUrl, appVersion })
|
||||
|
||||
const options = {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2025-11-30' as const,
|
||||
defaults: '2025-11-30',
|
||||
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
|
||||
before_send: (event: CaptureResult | null) => {
|
||||
if (!event) return event
|
||||
if (appVersion) {
|
||||
event.properties = {
|
||||
...event.properties,
|
||||
app_version: appVersion,
|
||||
}
|
||||
}
|
||||
return event
|
||||
},
|
||||
loaded: () => {
|
||||
configureAnalyticsContext({ apiUrl, appVersion })
|
||||
},
|
||||
}
|
||||
} as const
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
|
@ -54,7 +36,11 @@ async function bootstrap() {
|
|||
</StrictMode>,
|
||||
)
|
||||
|
||||
// The loaded callback applies api_url/app_version once PostHog has initialized.
|
||||
// Tag the active person record with api_url so anonymous users are also
|
||||
// segmentable by environment.
|
||||
if (apiUrl) {
|
||||
posthog.people.set({ api_url: apiUrl })
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
|
|
|||
|
|
@ -5,15 +5,10 @@
|
|||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -w -p tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"build": "rm -rf dist && tsc",
|
||||
"dev": "tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@ai-sdk/anthropic": "^2.0.63",
|
||||
"@ai-sdk/google": "^2.0.53",
|
||||
"@ai-sdk/openai": "^2.0.91",
|
||||
|
|
@ -34,8 +29,8 @@
|
|||
"express": "^5.2.1",
|
||||
"glob": "^13.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"googleapis": "^169.0.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
"googleapis": "^169.0.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-html-markdown": "^2.0.0",
|
||||
"ollama-ai-provider-v2": "^1.5.4",
|
||||
|
|
@ -53,7 +48,6 @@
|
|||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"vitest": "catalog:"
|
||||
"@types/pdf-parse": "^1.1.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,17 @@ import fs from "fs";
|
|||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
||||
import { execTool } from "../application/lib/exec-tool.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
|
||||
import { buildBackgroundTaskAgent } from "../background-tasks/agent.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
||||
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
|
|
@ -23,7 +21,7 @@ import { resolveProviderConfig } from "../models/defaults.js";
|
|||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IRunsLock } from "../runs/lock.js";
|
||||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
|
|
@ -36,146 +34,14 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
|||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
|
||||
|
||||
// Work directory is scoped per run (per chat). Each run gets its own sidecar
|
||||
// config file so setting it in one chat does not leak into others.
|
||||
function workDirConfigFile(runId: string): string {
|
||||
return path.join(WorkDir, 'config', `workdir-${runId}.json`);
|
||||
}
|
||||
|
||||
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean {
|
||||
return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath));
|
||||
}
|
||||
|
||||
function commonPathPrefix(paths: string[]): string {
|
||||
if (!paths.length) return path.resolve(WorkDir);
|
||||
const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean));
|
||||
const first = split[0];
|
||||
const common: string[] = [];
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
if (split.every(parts => parts[i] === first[i])) {
|
||||
common.push(first[i]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const prefix = `${path.sep}${common.join(path.sep)}`;
|
||||
return prefix === path.sep ? prefix : path.resolve(prefix);
|
||||
}
|
||||
|
||||
function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string {
|
||||
if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') {
|
||||
return commonPathPrefix(resolvedPaths);
|
||||
}
|
||||
const parentPaths = resolvedPaths.map(p => path.dirname(p));
|
||||
return commonPathPrefix(parentPaths);
|
||||
}
|
||||
|
||||
function filePermissionTargets(toolName: string, args: Record<string, unknown>): { operation: FileAccessOperation; paths: string[] } | null {
|
||||
const pathArg = typeof args.path === 'string' ? args.path : undefined;
|
||||
switch (toolName) {
|
||||
case 'file-readText':
|
||||
case 'parseFile':
|
||||
case 'LLMParse':
|
||||
case 'file-exists':
|
||||
case 'file-stat':
|
||||
return pathArg ? { operation: 'read', paths: [pathArg] } : null;
|
||||
case 'file-list':
|
||||
return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null;
|
||||
case 'file-glob':
|
||||
return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] };
|
||||
case 'file-grep':
|
||||
return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] };
|
||||
case 'file-writeText':
|
||||
case 'file-editText':
|
||||
case 'file-mkdir':
|
||||
return pathArg ? { operation: 'write', paths: [pathArg] } : null;
|
||||
case 'file-copy':
|
||||
case 'file-rename': {
|
||||
const from = typeof args.from === 'string' ? args.from : undefined;
|
||||
const to = typeof args.to === 'string' ? args.to : undefined;
|
||||
return from && to ? { operation: 'write', paths: [from, to] } : null;
|
||||
}
|
||||
case 'file-remove':
|
||||
return pathArg ? { operation: 'delete', paths: [pathArg] } : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getToolPermissionMetadata(
|
||||
toolCall: z.infer<typeof ToolCallPart>,
|
||||
underlyingTool: z.infer<typeof ToolAttachment>,
|
||||
sessionAllowedCommands: Set<string>,
|
||||
sessionAllowedFileAccess: FileAccessGrant[],
|
||||
): Promise<ToolPermissionMetadataValue | null> {
|
||||
if (underlyingTool.type !== 'builtin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (underlyingTool.name === 'executeCommand') {
|
||||
const args = toolCall.arguments;
|
||||
if (!args || typeof args !== 'object' || !('command' in args)) {
|
||||
return null;
|
||||
}
|
||||
const command = String((args as { command: unknown }).command);
|
||||
if (!isBlocked(command, sessionAllowedCommands)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'command',
|
||||
commandNames: extractCommandNames(command),
|
||||
};
|
||||
}
|
||||
|
||||
const args = toolCall.arguments && typeof toolCall.arguments === 'object'
|
||||
? toolCall.arguments as Record<string, unknown>
|
||||
: {};
|
||||
const targets = filePermissionTargets(underlyingTool.name, args);
|
||||
if (!targets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p)));
|
||||
const outsideWorkspacePaths = resolvedTargets
|
||||
.filter(target => !target.isInsideWorkspace)
|
||||
.map(target => target.canonicalPath);
|
||||
if (!outsideWorkspacePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistentGrants = getFileAccessAllowList();
|
||||
const allGrants = [...persistentGrants, ...sessionAllowedFileAccess];
|
||||
const uncovered = outsideWorkspacePaths.filter(resolvedPath =>
|
||||
!allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath))
|
||||
);
|
||||
if (!uncovered.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
operation: targets.operation,
|
||||
paths: uncovered,
|
||||
pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered),
|
||||
};
|
||||
}
|
||||
|
||||
function loadUserWorkDir(runId: string): string | null {
|
||||
function loadUserWorkDir(): string | null {
|
||||
try {
|
||||
const file = workDirConfigFile(runId);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
||||
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { path?: unknown };
|
||||
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
||||
return value || null;
|
||||
|
|
@ -229,103 +95,13 @@ function loadAgentNotesContext(): string | null {
|
|||
} catch { /* ignore */ }
|
||||
|
||||
if (otherFiles.length > 0) {
|
||||
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
|
||||
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using workspace-readFile. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
||||
}
|
||||
|
||||
function isCopilotLikeAgent(agentName: string | null | undefined): boolean {
|
||||
return agentName === 'copilot' || agentName === 'rowboatx';
|
||||
}
|
||||
|
||||
function formatCurrentDateTime(now: Date): string {
|
||||
return now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function toUserMessageContextMiddlePane(middlePaneContext: MiddlePaneContext | null): z.infer<typeof UserMessageContext>['middlePane'] {
|
||||
if (!middlePaneContext) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
if (middlePaneContext.kind === 'note') {
|
||||
return {
|
||||
kind: 'note',
|
||||
path: middlePaneContext.path,
|
||||
content: middlePaneContext.content,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'browser',
|
||||
url: middlePaneContext.url,
|
||||
title: middlePaneContext.title,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserMessageContext({
|
||||
agentName,
|
||||
middlePaneContext,
|
||||
}: {
|
||||
agentName: string | null | undefined;
|
||||
middlePaneContext: MiddlePaneContext | null;
|
||||
}): z.infer<typeof UserMessageContext> {
|
||||
return {
|
||||
currentDateTime: formatCurrentDateTime(new Date()),
|
||||
...(isCopilotLikeAgent(agentName)
|
||||
? { middlePane: toUserMessageContextMiddlePane(middlePaneContext) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatUserMessageContextForLlm(userMessageContext: z.infer<typeof UserMessageContext>): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (userMessageContext.currentDateTime) {
|
||||
sections.push(`Current date and time: ${userMessageContext.currentDateTime}`);
|
||||
}
|
||||
|
||||
if (userMessageContext.middlePane) {
|
||||
if (userMessageContext.middlePane.kind === 'empty') {
|
||||
sections.push(`Middle pane:\nState: empty`);
|
||||
} else if (userMessageContext.middlePane.kind === 'note') {
|
||||
sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``);
|
||||
} else {
|
||||
sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `# User Context
|
||||
${sections.join('\n\n')}
|
||||
|
||||
# User Message
|
||||
`;
|
||||
}
|
||||
|
||||
const USER_CONTEXT_SYSTEM_INSTRUCTIONS = `# Hidden User Context
|
||||
User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message".
|
||||
|
||||
Use "Current date and time" for temporal reasoning.
|
||||
|
||||
If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current.
|
||||
|
||||
If Middle pane state is empty, the user was not looking at any relevant note or web page at that point. Answer the user's message on its own merits.
|
||||
|
||||
If Middle pane state is note, the supplied path and content are available so you can reference the note when relevant. The user may or may not be talking about this note. Do NOT assume every message is about it. Only reference or act on this note when the user's message clearly relates to it, such as "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly the note's content. For unrelated questions, ignore this note entirely and answer normally. Do not mention that you can see this note unless it is relevant to the answer.
|
||||
|
||||
If Middle pane state is browser, only the URL and page title are supplied; the page content itself is NOT included. If you need the page content to answer, use the browser tools available to you to read the page. The user may or may not be talking about this page. Only reference or act on this page when the user's message clearly relates to it, such as "this page", "this article", "what I'm looking at", "this site", or "summarize this". For unrelated questions, ignore this page entirely and answer normally. Do not mention that you can see the browser unless it is relevant to the answer.`;
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -483,10 +259,9 @@ export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<T
|
|||
case "builtin": {
|
||||
if (t.name === "ask-human") {
|
||||
return tool({
|
||||
description: "Ask a human before proceeding. Optionally pass `options` (an array of short button labels) to render the question as a one-click choice; the user's response will be the chosen label verbatim.",
|
||||
description: "Ask a human before proceeding",
|
||||
inputSchema: z.object({
|
||||
question: z.string().describe("The question to ask the human"),
|
||||
options: z.array(z.string()).optional().describe("Optional short button labels (2-4 recommended). If provided, the user picks one with a single click instead of typing. The response you receive will be the chosen label."),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -813,18 +588,17 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
providerOptions,
|
||||
});
|
||||
break;
|
||||
case "user": {
|
||||
const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : '';
|
||||
case "user":
|
||||
if (typeof msg.content === 'string') {
|
||||
// Legacy string — pass through unchanged
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `${userMessageContextPrefix}${msg.content}`,
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
// New content parts array — collapse to text for LLM
|
||||
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
|
||||
const textSegments: string[] = [];
|
||||
const attachmentLines: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
|
|
@ -838,11 +612,7 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
}
|
||||
|
||||
if (attachmentLines.length > 0) {
|
||||
if (userMessageContextPrefix) {
|
||||
textSegments.push("User has attached the following files:", ...attachmentLines, "");
|
||||
} else {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
|
||||
result.push({
|
||||
|
|
@ -852,7 +622,6 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool":
|
||||
result.push({
|
||||
role: "tool",
|
||||
|
|
@ -902,7 +671,6 @@ export class AgentState {
|
|||
agentName: string | null = null;
|
||||
runModel: string | null = null;
|
||||
runProvider: string | null = null;
|
||||
permissionMode: "manual" | "auto" = "manual";
|
||||
runUseCase: UseCase | null = null;
|
||||
runSubUseCase: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
|
|
@ -914,10 +682,7 @@ export class AgentState {
|
|||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||
allowedToolCallIds: Record<string, true> = {};
|
||||
deniedToolCallIds: Record<string, true> = {};
|
||||
autoAllowedToolCalls: Record<string, { reason: string }> = {};
|
||||
autoDeniedToolCalls: Record<string, { reason: string }> = {};
|
||||
sessionAllowedCommands: Set<string> = new Set();
|
||||
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
||||
|
||||
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||
|
|
@ -1023,7 +788,6 @@ export class AgentState {
|
|||
this.agentName = event.agentName;
|
||||
this.runModel = event.model;
|
||||
this.runProvider = event.provider;
|
||||
this.permissionMode = event.permissionMode ?? "manual";
|
||||
this.runUseCase = event.useCase ?? null;
|
||||
this.runSubUseCase = event.subUseCase ?? null;
|
||||
break;
|
||||
|
|
@ -1036,7 +800,6 @@ export class AgentState {
|
|||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||
this.subflowStates[event.toolCallId].permissionMode = this.permissionMode;
|
||||
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||
break;
|
||||
|
|
@ -1065,15 +828,6 @@ export class AgentState {
|
|||
switch (event.response) {
|
||||
case "approve":
|
||||
this.allowedToolCallIds[event.toolCallId] = true;
|
||||
{
|
||||
const permissionRequest = this.pendingToolPermissionRequests[event.toolCallId];
|
||||
if (event.scope === "session" && permissionRequest?.permission?.kind === "file") {
|
||||
this.sessionAllowedFileAccess.push({
|
||||
operation: permissionRequest.permission.operation,
|
||||
pathPrefix: permissionRequest.permission.pathPrefix,
|
||||
});
|
||||
}
|
||||
}
|
||||
// For session scope, extract command names and add to session allowlist
|
||||
if (event.scope === "session") {
|
||||
const toolCall = this.toolCallIdMap[event.toolCallId];
|
||||
|
|
@ -1087,22 +841,10 @@ export class AgentState {
|
|||
break;
|
||||
case "deny":
|
||||
this.deniedToolCallIds[event.toolCallId] = true;
|
||||
delete this.autoDeniedToolCalls[event.toolCallId];
|
||||
break;
|
||||
}
|
||||
delete this.pendingToolPermissionRequests[event.toolCallId];
|
||||
break;
|
||||
case "tool-permission-auto-decision":
|
||||
switch (event.decision) {
|
||||
case "allow":
|
||||
this.allowedToolCallIds[event.toolCallId] = true;
|
||||
this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason };
|
||||
break;
|
||||
case "deny":
|
||||
this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason };
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "ask-human-request":
|
||||
this.pendingAskHumanRequests[event.toolCallId] = event;
|
||||
break;
|
||||
|
|
@ -1180,7 +922,6 @@ export async function* streamAgent({
|
|||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
let codeMode: 'claude' | 'codex' | null = null;
|
||||
let middlePaneContext:
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string }
|
||||
|
|
@ -1208,19 +949,13 @@ export async function* streamAgent({
|
|||
// if tool has been denied, deny
|
||||
if (state.deniedToolCallIds[toolCallId]) {
|
||||
_logger.log('returning denied tool message, reason: tool has been denied');
|
||||
const autoDenied = state.autoDeniedToolCalls[toolCallId];
|
||||
yield* processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: {
|
||||
role: "tool",
|
||||
content: autoDenied
|
||||
? JSON.stringify({
|
||||
success: false,
|
||||
error: `Auto-permission denied: ${autoDenied.reason}`,
|
||||
})
|
||||
: "Unable to execute this tool: Permission was denied.",
|
||||
content: "Unable to execute this tool: Permission was denied.",
|
||||
toolCallId: toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
},
|
||||
|
|
@ -1279,7 +1014,6 @@ export async function* streamAgent({
|
|||
signal,
|
||||
abortRegistry,
|
||||
publish: (event) => bus.publish(event),
|
||||
codeMode,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1336,9 +1070,6 @@ export async function* streamAgent({
|
|||
if (msg.searchEnabled) {
|
||||
searchEnabled = true;
|
||||
}
|
||||
// Code mode is per-message: latest message decides whether the assistant
|
||||
// should route coding work through the code-with-agents skill / chosen agent.
|
||||
codeMode = msg.codeMode ?? null;
|
||||
if (msg.voiceOutput) {
|
||||
voiceOutput = msg.voiceOutput;
|
||||
}
|
||||
|
|
@ -1346,10 +1077,6 @@ export async function* streamAgent({
|
|||
// latest user message. If the user closed the pane between messages, clear it.
|
||||
middlePaneContext = msg.middlePaneContext ?? null;
|
||||
loopLogger.log('dequeued user message', msg.messageId);
|
||||
const userMessageContext = buildUserMessageContext({
|
||||
agentName: state.agentName,
|
||||
middlePaneContext,
|
||||
});
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "message",
|
||||
|
|
@ -1357,7 +1084,6 @@ export async function* streamAgent({
|
|||
message: {
|
||||
role: "user",
|
||||
content: msg.message,
|
||||
userMessageContext,
|
||||
},
|
||||
subflow: [],
|
||||
});
|
||||
|
|
@ -1379,14 +1105,24 @@ export async function* streamAgent({
|
|||
loopLogger.log('running llm turn');
|
||||
// stream agent response and build message
|
||||
const messageBuilder = new StreamStepMessageBuilder();
|
||||
let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
|
||||
const now = new Date();
|
||||
const currentDateTime = now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
||||
// Inject Agent Notes context for copilot
|
||||
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
|
||||
const agentNotesContext = loadAgentNotesContext();
|
||||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
const userWorkDir = loadUserWorkDir(runId);
|
||||
const userWorkDir = loadUserWorkDir();
|
||||
if (userWorkDir) {
|
||||
loopLogger.log('injecting user work directory', userWorkDir);
|
||||
instructionsWithDateTime += `\n\n# User Work Directory
|
||||
|
|
@ -1399,15 +1135,28 @@ Treat this as the **default location** for file operations whenever the user ref
|
|||
- "save this", "export it", "write that to a file" — write the output into the work directory unless the user names another location.
|
||||
- "open the file I was just working on", "the doc from earlier" — assume the work directory first.
|
||||
|
||||
Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
|
||||
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
|
||||
|
||||
**Exceptions — these ALWAYS take precedence over the work directory default:**
|
||||
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
|
||||
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
|
||||
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
|
||||
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
|
||||
|
||||
Do not announce the work directory unless it's relevant. Just use it.`;
|
||||
}
|
||||
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
|
||||
// that supersedes any earlier middle-pane mention in the conversation history.
|
||||
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
|
||||
if (!middlePaneContext) {
|
||||
loopLogger.log('injecting middle pane context (empty)');
|
||||
instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`;
|
||||
} else if (middlePaneContext.kind === 'note') {
|
||||
loopLogger.log('injecting middle pane context (note)', middlePaneContext.path);
|
||||
instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``;
|
||||
} else if (middlePaneContext.kind === 'browser') {
|
||||
loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url);
|
||||
instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`;
|
||||
}
|
||||
}
|
||||
if (voiceInput) {
|
||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
||||
|
|
@ -1424,25 +1173,6 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
|||
loopLogger.log('search enabled, injecting search prompt');
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
|
||||
}
|
||||
if (codeMode) {
|
||||
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
|
||||
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
|
||||
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
|
||||
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
|
||||
|
||||
The chip is the single source of truth for which agent runs:
|
||||
- Do NOT carry over a different agent from earlier in this thread — even if a previous run used the other agent, use **${agentDisplay}** now.
|
||||
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
|
||||
|
||||
**How to run coding work — call the \`code_agent_run\` tool** with:
|
||||
- \`agent\`: \`${codeMode}\` (always — match the chip).
|
||||
- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once).
|
||||
- \`prompt\`: a clear, self-contained coding instruction.
|
||||
|
||||
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context — just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.
|
||||
|
||||
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
|
||||
}
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
model,
|
||||
|
|
@ -1493,33 +1223,30 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
|
||||
// if there were any ask-human calls, emit those events
|
||||
if (message.content instanceof Array) {
|
||||
const permissionCandidates: AutoPermissionCandidate[] = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-call") {
|
||||
const underlyingTool = agent.tools![part.toolName];
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
|
||||
const rawOptions = (part.arguments as { options?: unknown }).options;
|
||||
const options = Array.isArray(rawOptions)
|
||||
? rawOptions.filter((o): o is string => typeof o === 'string' && o.trim().length > 0)
|
||||
: undefined;
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "ask-human-request",
|
||||
toolCallId: part.toolCallId,
|
||||
query: part.arguments.question,
|
||||
...(options && options.length > 0 ? { options } : {}),
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
const permission = await getToolPermissionMetadata(
|
||||
part,
|
||||
underlyingTool,
|
||||
state.sessionAllowedCommands,
|
||||
state.sessionAllowedFileAccess,
|
||||
);
|
||||
if (permission) {
|
||||
permissionCandidates.push({ toolCall: part, permission });
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||
// if command is blocked, then seek permission
|
||||
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: part,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
||||
|
|
@ -1543,87 +1270,6 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionCandidates.length > 0) {
|
||||
if (state.permissionMode === "auto") {
|
||||
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
|
||||
try {
|
||||
const decisions = await classifyToolPermissions({
|
||||
runId,
|
||||
agentName: state.agentName,
|
||||
messages: convertFromMessages(state.messages),
|
||||
candidates: permissionCandidates,
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
subUseCase: state.runSubUseCase,
|
||||
});
|
||||
decisionsByToolCallId = new Map(decisions.map((decision) => [
|
||||
decision.toolCallId,
|
||||
{ decision: decision.decision, reason: decision.reason },
|
||||
]));
|
||||
} catch (error) {
|
||||
loopLogger.log(
|
||||
'auto-permission classifier failed:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of permissionCandidates) {
|
||||
const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId);
|
||||
if (!decision) {
|
||||
loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
loopLogger.log(
|
||||
'emitting tool-permission-auto-decision, toolCallId:',
|
||||
candidate.toolCall.toolCallId,
|
||||
'decision:',
|
||||
decision.decision,
|
||||
);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-auto-decision",
|
||||
toolCallId: candidate.toolCall.toolCallId,
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
decision: decision.decision,
|
||||
reason: decision.reason,
|
||||
subflow: [],
|
||||
});
|
||||
if (decision.decision === "deny") {
|
||||
loopLogger.log(
|
||||
'auto-permission denied, falling back to prompt:',
|
||||
candidate.toolCall.toolCallId,
|
||||
);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const candidate of permissionCandidates) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { API_URL } from '../config/env.js';
|
|||
// In dev/tsc, fall back to process.env so local runs work too.
|
||||
const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? '';
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com';
|
||||
const APP_VERSION = (process.env.ROWBOAT_APP_VERSION ?? process.env.npm_package_version ?? '').trim();
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let initAttempted = false;
|
||||
|
|
@ -30,7 +29,7 @@ function getClient(): PostHog | null {
|
|||
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
||||
client.identify({
|
||||
distinctId: getInstallationId(),
|
||||
properties: { api_url: API_URL, ...appVersionProperties() },
|
||||
properties: { api_url: API_URL },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to init PostHog:', err);
|
||||
|
|
@ -43,10 +42,6 @@ function activeDistinctId(): string {
|
|||
return identifiedUserId ?? getInstallationId();
|
||||
}
|
||||
|
||||
function appVersionProperties(): Record<string, string> {
|
||||
return APP_VERSION ? { app_version: APP_VERSION } : {};
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
|
|
@ -54,10 +49,7 @@ export function capture(event: string, properties?: Record<string, unknown>): vo
|
|||
ph.capture({
|
||||
distinctId: activeDistinctId(),
|
||||
event,
|
||||
properties: {
|
||||
...properties,
|
||||
...appVersionProperties(),
|
||||
},
|
||||
properties,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] capture failed:', err);
|
||||
|
|
@ -76,7 +68,6 @@ export function identify(userId: string, properties?: Record<string, unknown>):
|
|||
properties: {
|
||||
...properties,
|
||||
api_url: API_URL,
|
||||
...appVersionProperties(),
|
||||
},
|
||||
});
|
||||
identifiedUserId = userId;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
|
|||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
||||
import container from "../../di/container.js";
|
||||
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
|
|
@ -31,7 +29,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any
|
|||
`;
|
||||
}
|
||||
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
|
||||
// Conditionally include Composio-related instruction sections
|
||||
const emailDraftSuffix = composioEnabled
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||
|
|
@ -82,9 +80,7 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
|
||||
|
||||
${codeModeEnabled
|
||||
? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
|
||||
: `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
|
||||
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
|
||||
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
|
|
@ -144,39 +140,39 @@ Users can interact with the knowledge graph through you, open it directly in Obs
|
|||
**CRITICAL PATH REQUIREMENT:**
|
||||
- The workspace root is the configured workdir
|
||||
- The knowledge base is in the \`knowledge/\` subfolder
|
||||
- When searching knowledge, ALWAYS include \`knowledge/\` in the search path
|
||||
- **WRONG:** \`file-grep({ pattern: "John", searchPath: "" })\` or \`searchPath: "."\` or any absolute path to the workspace root
|
||||
- **CORRECT:** \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
|
||||
- When using workspace tools, ALWAYS include \`knowledge/\` in the path
|
||||
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root
|
||||
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
|
||||
|
||||
Use the builtin file tools to search and read the knowledge base:
|
||||
Use the builtin workspace tools to search and read the knowledge base:
|
||||
|
||||
**Finding notes:**
|
||||
\`\`\`
|
||||
# List all people notes
|
||||
file-list("knowledge/People")
|
||||
workspace-readdir("knowledge/People")
|
||||
|
||||
# Search for a person by name - MUST include knowledge/ in path
|
||||
file-grep({ pattern: "Sarah Chen", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "Sarah Chen", path: "knowledge/" })
|
||||
|
||||
# Find notes mentioning a company - MUST include knowledge/ in path
|
||||
file-grep({ pattern: "Acme Corp", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "Acme Corp", path: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
**Reading notes:**
|
||||
\`\`\`
|
||||
# Read a specific person's note
|
||||
file-readText("knowledge/People/Sarah Chen.md")
|
||||
workspace-readFile("knowledge/People/Sarah Chen.md")
|
||||
|
||||
# Read an organization note
|
||||
file-readText("knowledge/Organizations/Acme Corp.md")
|
||||
workspace-readFile("knowledge/Organizations/Acme Corp.md")
|
||||
\`\`\`
|
||||
|
||||
**When a user mentions someone by name:**
|
||||
1. First, search for them: \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
|
||||
2. Read their note to get full context: \`file-readText("knowledge/People/John Smith.md")\`
|
||||
1. First, search for them: \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
|
||||
2. Read their note to get full context: \`workspace-readFile("knowledge/People/John Smith.md")\`
|
||||
3. Use the context (role, organization, past interactions, commitments) in your response
|
||||
|
||||
**NEVER use an empty search path or root path for knowledge lookup. ALWAYS set searchPath to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
|
||||
**NEVER use an empty path or root path. ALWAYS set path to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
|
||||
|
||||
## When to Access the Knowledge Graph
|
||||
|
||||
|
|
@ -241,23 +237,29 @@ ${toolPriority}
|
|||
|
||||
${runtimeContextPrompt}
|
||||
|
||||
## File Access & Scope
|
||||
- Use builtin file tools (\`file-readText\`, \`file-writeText\`, \`file-editText\`, etc.) for normal file work anywhere on the user's machine.
|
||||
- Relative paths resolve against the Rowboat workspace root. Use paths like \`knowledge/People/Ada.md\` for knowledge files.
|
||||
- Use absolute paths or \`~/...\` paths when the user refers to Desktop, Downloads, Documents, the injected work directory, or any other location outside the Rowboat workspace.
|
||||
- File operations inside the Rowboat workspace normally run without approval. File operations outside the workspace may trigger a permission prompt; this is expected.
|
||||
- Do NOT use \`executeCommand\` just to read, write, edit, list, search, move, copy, or remove files. Use file tools and let the permission system handle access.
|
||||
- Do NOT read binary files as text. Use \`parseFile\` or \`LLMParse\` for PDFs, Office docs, images, scanned docs, presentations, and other non-text formats.
|
||||
- Do NOT access files outside the workspace unless the user explicitly asks you to or the current task clearly requires it.
|
||||
## Workspace Access & Scope
|
||||
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||
|
||||
**CRITICAL - When the user asks you to work with files outside the workspace root:**
|
||||
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
|
||||
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
||||
- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
||||
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
||||
- NEVER ask what OS the user is on if runtime platform is already available.
|
||||
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
||||
|
||||
## Builtin Tools vs Shell Commands
|
||||
|
||||
**IMPORTANT**: Rowboat provides builtin tools:
|
||||
- \`file-readText\`, \`file-writeText\`, \`file-editText\`, \`file-remove\` - File operations
|
||||
- \`file-list\`, \`file-exists\`, \`file-stat\`, \`file-glob\`, \`file-grep\` - Directory exploration and file search
|
||||
- \`file-mkdir\`, \`file-rename\`, \`file-copy\` - File/directory management
|
||||
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute, ~/..., or relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
|
||||
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval:
|
||||
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations
|
||||
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search
|
||||
- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management
|
||||
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
|
||||
- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images.
|
||||
- \`analyzeAgent\` - Agent analysis
|
||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
|
|
@ -268,21 +270,23 @@ ${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with fu
|
|||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||
${composioToolsLine}
|
||||
|
||||
**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`.
|
||||
|
||||
**Shell commands via \`executeCommand\`:**
|
||||
- You can run shell commands via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
|
||||
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
|
||||
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
|
||||
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
|
||||
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
|
||||
- Always confirm with the user before executing commands that modify files outside the workspace root. Prefer file tools for file changes.
|
||||
- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
|
||||
**CRITICAL: MCP Server Configuration**
|
||||
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
|
||||
- NEVER manually edit \`config/mcp.json\` using \`file-writeText\` for MCP servers
|
||||
- NEVER manually edit \`config/mcp.json\` using \`workspace-writeFile\` for MCP servers
|
||||
- Invalid MCP configs will prevent the agent from starting with validation errors
|
||||
|
||||
File tools and \`executeCommand\` can both go through the approval flow depending on the path or command. If you need to delete a file, use \`file-remove\`, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`file-writeText\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
||||
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
||||
|
||||
Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.
|
||||
|
||||
## File Path References
|
||||
|
||||
|
|
@ -316,29 +320,30 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
|
|||
/** Keep backward-compatible export for any external consumers */
|
||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||
|
||||
/**
|
||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
let cachedInstructions: string | null = null;
|
||||
|
||||
/**
|
||||
* Invalidate the cached instructions so the next buildCopilotInstructions() call
|
||||
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
|
||||
*/
|
||||
export function invalidateCopilotInstructionsCache(): void {
|
||||
cachedInstructions = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full copilot instructions with dynamic Composio tools section.
|
||||
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioEnabled = await isComposioConfigured();
|
||||
let codeModeEnabled = false;
|
||||
try {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
codeModeEnabled = (await repo.getConfig()).enabled;
|
||||
} catch {
|
||||
// repo unavailable — default to disabled
|
||||
}
|
||||
const excludeIds: string[] = [];
|
||||
if (!composioEnabled) excludeIds.push('composio-integration');
|
||||
if (!codeModeEnabled) excludeIds.push('code-with-agents');
|
||||
const catalog = excludeIds.length > 0
|
||||
? buildSkillCatalog({ excludeIds })
|
||||
: skillCatalog;
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
|
||||
const catalog = composioEnabled
|
||||
? skillCatalog
|
||||
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
cachedInstructions = composioPrompt
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Open a specific knowledge file in the editor pane.
|
|||
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
|
||||
|
||||
**Tips:**
|
||||
- Use ` + "`file-grep`" + ` first to find the exact path if you're unsure of the filename.
|
||||
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename.
|
||||
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
|
||||
|
||||
### open-view
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ Mixed instructions ("summarize and email it") trigger both.
|
|||
|
||||
You have three dedicated builtin tools for this skill:
|
||||
|
||||
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`file-editText\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
|
||||
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`workspace-edit\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
|
||||
- \`patch-background-task\` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case.
|
||||
- \`run-background-task-agent\` — manually fires a task to run now. Always call this immediately after \`create-background-task\` so the user sees content.
|
||||
|
||||
To inspect what tasks already exist, use \`file-glob\` on \`bg-tasks/*/task.yaml\` and \`file-readText\` on candidates. The user's bg-tasks folder is workspace-relative.
|
||||
To inspect what tasks already exist, use \`workspace-glob\` on \`bg-tasks/*/task.yaml\` and \`workspace-readFile\` on candidates. The user's bg-tasks folder is workspace-relative.
|
||||
|
||||
## Mode: act-first
|
||||
|
||||
|
|
|
|||
|
|
@ -158,26 +158,26 @@ Pass the paper URL to the summariser. Don't ask for human input.
|
|||
|
||||
## Additional Builtin Tools
|
||||
|
||||
While \`executeCommand\` is useful for CLI tools and shell workflows, builtin file tools exist for normal file management. Use \`file-*\` tools for reading, writing, editing, listing, searching, moving, copying, and removing files instead of shell commands.
|
||||
While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`.
|
||||
|
||||
### Copilot-Specific Builtin Tools
|
||||
|
||||
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with file management, app workflows, and MCP integration:
|
||||
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with workspace management and MCP integration:
|
||||
|
||||
#### File & Directory Operations
|
||||
- \`file-list\` - List directory contents (supports recursive exploration)
|
||||
- \`file-readText\` - Read file contents
|
||||
- \`file-writeText\` - Create or update file contents
|
||||
- \`file-editText\` - Make precise edits by replacing specific text (safer than full rewrites)
|
||||
- \`file-remove\` - Remove files or directories
|
||||
- \`file-exists\` - Check if a file or directory exists
|
||||
- \`file-stat\` - Get file/directory statistics
|
||||
- \`file-mkdir\` - Create directories
|
||||
- \`file-rename\` - Rename or move files/directories
|
||||
- \`file-copy\` - Copy files
|
||||
- \`file-getRoot\` - Get the default root for relative file paths
|
||||
- \`file-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
|
||||
- \`file-grep\` - Search file contents using regex, returns matching files and lines
|
||||
- \`workspace-readdir\` - List directory contents (supports recursive exploration)
|
||||
- \`workspace-readFile\` - Read file contents
|
||||
- \`workspace-writeFile\` - Create or update file contents
|
||||
- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites)
|
||||
- \`workspace-remove\` - Remove files or directories
|
||||
- \`workspace-exists\` - Check if a file or directory exists
|
||||
- \`workspace-stat\` - Get file/directory statistics
|
||||
- \`workspace-mkdir\` - Create directories
|
||||
- \`workspace-rename\` - Rename or move files/directories
|
||||
- \`workspace-copy\` - Copy files
|
||||
- \`workspace-getRoot\` - Get workspace root directory path
|
||||
- \`workspace-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
|
||||
- \`workspace-grep\` - Search file contents using regex, returns matching files and lines
|
||||
|
||||
#### Agent Operations
|
||||
- \`analyzeAgent\` - Read and analyze an agent file structure
|
||||
|
|
|
|||
|
|
@ -1,98 +1,90 @@
|
|||
export const skill = String.raw`
|
||||
# Code with Agents Skill
|
||||
|
||||
Use this skill whenever the user asks you to write code, build a project, create scripts, fix bugs, read/explain code, or do any software development task — even simple file creations like "make a .c file".
|
||||
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
|
||||
|
||||
Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself.
|
||||
## Important: delegate ALL coding work
|
||||
|
||||
All coding work runs through the **\`code_agent_run\`** tool. It launches the selected on-device coding agent (Claude Code / Codex), streams its tool calls, file diffs, and plan into the chat, and surfaces any action needing approval as an inline permission card. One persistent session is kept per chat, so follow-up requests resume with full context automatically.
|
||||
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
|
||||
- Writing, editing, or refactoring code
|
||||
- Reading, summarizing, or explaining code
|
||||
- Debugging and fixing bugs
|
||||
- Running tests or build commands
|
||||
- Exploring project structure
|
||||
- Any other task that involves interacting with a codebase
|
||||
|
||||
---
|
||||
Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
|
||||
|
||||
## STEP 1 — MANDATORY FIRST ACTION
|
||||
## Prerequisites
|
||||
|
||||
Look in your **system context** for a section titled **"# Code Mode (Active)"**.
|
||||
The user must have one of the following installed on their machine:
|
||||
- **Claude Code** — https://claude.ai/code
|
||||
- **Codex** — https://codex.openai.com
|
||||
|
||||
### Case A — "# Code Mode (Active)" IS present
|
||||
These are external tools that you cannot install for the user.
|
||||
|
||||
Code mode is on and the user has selected an agent. Skip directly to Step 2. Do NOT call ask-human.
|
||||
## Workflow
|
||||
|
||||
### Case B — "# Code Mode (Active)" is NOT present
|
||||
### Step 1: Gather requirements
|
||||
|
||||
Your **very next tool call MUST be \`ask-human\`** with options. Do not write any explanation text first. Do not describe a plan. Do not check the workspace boundary. Just call:
|
||||
Before running anything, confirm the following with the user:
|
||||
|
||||
\`\`\`
|
||||
ask-human({
|
||||
question: "How should I handle this coding request?",
|
||||
options: [
|
||||
"Use code mode (Claude Code)",
|
||||
"Use code mode (Codex)",
|
||||
"Continue with default Rowboat"
|
||||
]
|
||||
})
|
||||
\`\`\`
|
||||
1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
|
||||
2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
|
||||
|
||||
This is non-negotiable. The user gets clickable buttons. Free-text "which agent?" questions are forbidden here.
|
||||
### Step 2: Confirm execution plan
|
||||
|
||||
**Branch on the response:**
|
||||
- "Use code mode (Claude Code)" → proceed to Step 2 with agent = \`claude\`.
|
||||
- "Use code mode (Codex)" → proceed to Step 2 with agent = \`codex\`.
|
||||
- "Continue with default Rowboat" → ABANDON this skill. Handle the request yourself using your own tools (workspace file tools, \`executeCommand\` shell, etc.). The rest of this skill does not apply for this turn.
|
||||
Once you know the folder and agent, tell the user:
|
||||
|
||||
---
|
||||
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
|
||||
|
||||
## STEP 2 — Resolve workdir, then run
|
||||
### Step 3: Execute with acpx
|
||||
|
||||
**Resolve the workdir** (in this priority order):
|
||||
1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`).
|
||||
2. The path from a "# User Work Directory" block in your context.
|
||||
3. Ask once in plain text: "Which folder should I work in?"
|
||||
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
|
||||
|
||||
**Pick the agent** (\`claude\` or \`codex\`): use the agent from the "# Code Mode (Active)" block (the composer chip) / the Step 1 choice. The chip is authoritative — do NOT carry over a different agent from earlier in this thread, and do NOT switch on an in-chat text request ("use codex"); tell the user to toggle the chip instead.
|
||||
**For Claude Code:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
|
||||
` + "`" + `
|
||||
|
||||
**State your intent in one line, then call the tool immediately — do NOT wait for a "yes".** The tool's own permission cards are the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
|
||||
**For Codex:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
|
||||
` + "`" + `
|
||||
|
||||
> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
|
||||
### Critical: flag order
|
||||
|
||||
…and then immediately call:
|
||||
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
|
||||
|
||||
\`\`\`
|
||||
code_agent_run({
|
||||
agent: "<claude|codex>",
|
||||
cwd: "<resolved absolute folder>",
|
||||
prompt: "<clear, self-contained coding instruction>"
|
||||
})
|
||||
\`\`\`
|
||||
` + "`" + `
|
||||
npx acpx@latest [global flags] <agent> exec "<prompt>"
|
||||
` + "`" + `
|
||||
|
||||
**Writing good prompts for the agent:**
|
||||
- Be specific: file names, function signatures, expected behavior.
|
||||
- Mention constraints (language, framework, style).
|
||||
- Expand short user requests into clear, actionable instructions.
|
||||
**Correct:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
|
||||
` + "`" + `
|
||||
|
||||
**Follow-ups:** for every later coding request in this chat, just call \`code_agent_run\` again with the same \`cwd\` and the chip's current agent. The session resumes automatically — do NOT start over or re-explain prior context.
|
||||
**Wrong (will fail):**
|
||||
` + "`" + `
|
||||
npx acpx@latest claude --approve-all exec "fix the bug"
|
||||
` + "`" + `
|
||||
|
||||
---
|
||||
### Writing good prompts
|
||||
|
||||
## STEP 3 — Report results
|
||||
When constructing the prompt for the coding agent:
|
||||
- Be specific and detailed about what to build or fix
|
||||
- Include file names, function signatures, and expected behavior
|
||||
- Mention any constraints (language, framework, style)
|
||||
- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
|
||||
|
||||
After \`code_agent_run\` returns:
|
||||
- Pass through the agent's \`summary\` as-is. Do not rewrite it.
|
||||
- Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.)
|
||||
- Only add your own explanation if it failed:
|
||||
- \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**.
|
||||
- \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue.
|
||||
### Step 4: Report results
|
||||
|
||||
---
|
||||
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
|
||||
|
||||
## Once delegating: delegate fully
|
||||
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
|
||||
|
||||
After Step 2 fires, delegate ALL related coding tasks for this turn to \`code_agent_run\` — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
|
||||
|
||||
## Prerequisites (informational)
|
||||
|
||||
The user must have one of these installed locally — these are external tools you cannot install:
|
||||
- Claude Code — https://claude.ai/code
|
||||
- Codex — https://codex.openai.com
|
||||
- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
|
|||
|
|
@ -78,19 +78,19 @@ Map each point to a slide layout from the Available Layout Types below. For a ty
|
|||
|
||||
## Workflow
|
||||
|
||||
1. Use file-readText to check knowledge/ for relevant context about the company, product, team, etc.
|
||||
1. Use workspace-readFile to check knowledge/ for relevant context about the company, product, team, etc.
|
||||
2. Ensure Playwright is installed: \`npm install playwright && npx playwright install chromium\`
|
||||
3. Use file-getRoot to get the workspace root path.
|
||||
3. Use workspace-getRoot to get the workspace root path.
|
||||
4. Plan the narrative arc and slide outline (see Content Planning above).
|
||||
5. Use file-writeText to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each).
|
||||
5. Use workspace-writeFile to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each).
|
||||
6. **Perform the Post-Generation Validation (see below). Fix any issues before proceeding.**
|
||||
7. Use file-writeText to create the conversion script at tmp/convert.js (workspace-relative) — see Playwright Export section.
|
||||
7. Use workspace-writeFile to create the conversion script at tmp/convert.js (workspace-relative) — see Playwright Export section.
|
||||
8. Run it: \`node <WORKSPACE_ROOT>/tmp/convert.js\`
|
||||
9. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" and note the theme used.
|
||||
|
||||
**Critical**: Never show HTML code to the user. Never ask the user to run commands, install packages, or make technical decisions. The entire pipeline from content to PDF must be invisible to the user.
|
||||
|
||||
Use file-writeText and file-readText for ALL file operations. Do NOT use executeCommand to write or read files.
|
||||
Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT use executeCommand to write or read files.
|
||||
|
||||
## Post-Generation Validation (REQUIRED)
|
||||
|
||||
|
|
@ -142,14 +142,14 @@ html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !
|
|||
## Playwright Export
|
||||
|
||||
\`\`\`javascript
|
||||
// save as tmp/convert.js via file-writeText
|
||||
// save as tmp/convert.js via workspace-writeFile
|
||||
const { chromium } = require('playwright');
|
||||
const path = require('path');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
// Replace <WORKSPACE_ROOT> with the actual absolute path from file-getRoot
|
||||
// Replace <WORKSPACE_ROOT> with the actual absolute path from workspace-getRoot
|
||||
await page.goto('file://<WORKSPACE_ROOT>/tmp/presentation.html', { waitUntil: 'networkidle' });
|
||||
await page.pdf({
|
||||
path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'),
|
||||
|
|
@ -162,7 +162,7 @@ const path = require('path');
|
|||
})();
|
||||
\`\`\`
|
||||
|
||||
Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by file-getRoot.
|
||||
Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by workspace-getRoot.
|
||||
|
||||
## Available Layout Types (35 Templates)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ You are an expert document assistant helping the user create, edit, and refine d
|
|||
|
||||
## CRITICAL: Re-read Before Every Response
|
||||
|
||||
**Before every response, you MUST use file-readText to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version.
|
||||
**Before every response, you MUST use workspace-readFile to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version.
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
|
@ -55,12 +55,12 @@ When the user mentions a document name, search for it using multiple approaches:
|
|||
|
||||
1. **Search by name pattern** (handles partial matches, different cases):
|
||||
\`\`\`
|
||||
file-glob({ pattern: "**/*[name]*", cwd: "knowledge/" })
|
||||
workspace-glob({ pattern: "knowledge/**/*[name]*", path: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
2. **Search by content** (finds docs that mention the topic):
|
||||
\`\`\`
|
||||
file-grep({ pattern: "[name]", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "[name]", path: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
3. **Try common variations:**
|
||||
|
|
@ -106,7 +106,7 @@ workspace-createFile({
|
|||
**Types of requests:**
|
||||
|
||||
1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section"
|
||||
→ Make the edit immediately using file-editText
|
||||
→ Make the edit immediately using workspace-editFile
|
||||
|
||||
2. **Content generation** - "Write an intro", "Draft the executive summary", "Add a section about our approach"
|
||||
→ Generate the content and add it to the document
|
||||
|
|
@ -122,21 +122,21 @@ workspace-createFile({
|
|||
|
||||
### Step 3: Execute Changes
|
||||
|
||||
**For edits, use file-editText:**
|
||||
**For edits, use workspace-editFile:**
|
||||
\`\`\`
|
||||
file-editText({
|
||||
workspace-editFile({
|
||||
path: "knowledge/[path].md",
|
||||
oldString: "[exact text to replace]",
|
||||
newString: "[new text]"
|
||||
old_string: "[exact text to replace]",
|
||||
new_string: "[new text]"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For additions at the end:**
|
||||
\`\`\`
|
||||
file-editText({
|
||||
workspace-editFile({
|
||||
path: "knowledge/[path].md",
|
||||
oldString: "[last line or section]",
|
||||
newString: "[last line or section]\n\n[new content]"
|
||||
old_string: "[last line or section]",
|
||||
new_string: "[last line or section]\n\n[new content]"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -156,14 +156,14 @@ When the user mentions people, companies, or projects:
|
|||
|
||||
**Search for relevant notes:**
|
||||
\`\`\`
|
||||
file-grep({ pattern: "[Name]", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "[Name]", path: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
**Read relevant notes:**
|
||||
\`\`\`
|
||||
file-readText("knowledge/People/[Person].md")
|
||||
file-readText("knowledge/Organizations/[Company].md")
|
||||
file-readText("knowledge/Projects/[Project].md")
|
||||
workspace-readFile("knowledge/People/[Person].md")
|
||||
workspace-readFile("knowledge/Organizations/[Company].md")
|
||||
workspace-readFile("knowledge/Projects/[Project].md")
|
||||
\`\`\`
|
||||
|
||||
**Use the context:**
|
||||
|
|
@ -237,7 +237,7 @@ Renders a styled table from structured data.
|
|||
|
||||
### Block Guidelines
|
||||
- The JSON must be valid and on a single line (no pretty-printing)
|
||||
- Insert blocks using \`file-editText\` just like any other content
|
||||
- Insert blocks using \`workspace-editFile\` just like any other content
|
||||
- When the user asks for a chart, table, embed, or live dashboard — use blocks rather than plain Markdown tables or image links
|
||||
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
||||
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ When the user says "draft an email to Monica" or mentions ANY person, organizati
|
|||
1. **STOP** - Do not draft anything yet
|
||||
2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`):
|
||||
\`\`\`
|
||||
file-grep({ pattern: "Monica", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "Monica", path: "knowledge/" })
|
||||
\`\`\`
|
||||
3. **READ** - Read their note to understand who they are:
|
||||
\`\`\`
|
||||
file-readText("knowledge/People/Monica Smith.md")
|
||||
workspace-readFile("knowledge/People/Monica Smith.md")
|
||||
\`\`\`
|
||||
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
|
||||
5. **THEN DRAFT** - Only now draft the email, using this context
|
||||
|
|
@ -133,19 +133,19 @@ Before drafting, gather relevant context. **Always check the knowledge base firs
|
|||
First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`):
|
||||
\`\`\`
|
||||
# Search for the sender by name or email
|
||||
file-grep({ pattern: "sender_name_or_email", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "sender_name_or_email", path: "knowledge/" })
|
||||
|
||||
# List all people to find potential matches
|
||||
file-list("knowledge/People")
|
||||
workspace-readdir("knowledge/People")
|
||||
\`\`\`
|
||||
|
||||
Then read the relevant notes:
|
||||
\`\`\`
|
||||
# Read the sender's note
|
||||
file-readText("knowledge/People/Sender Name.md")
|
||||
workspace-readFile("knowledge/People/Sender Name.md")
|
||||
|
||||
# Read their organization's note
|
||||
file-readText("knowledge/Organizations/Company Name.md")
|
||||
workspace-readFile("knowledge/Organizations/Company Name.md")
|
||||
\`\`\`
|
||||
|
||||
Extract from these notes:
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [
|
|||
{
|
||||
id: "code-with-agents",
|
||||
title: "Code with Agents",
|
||||
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.",
|
||||
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
|
||||
content: codeWithAgentsSkill,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ When this skill is loaded, your job is: make a passive note live (or extend the
|
|||
|
||||
## Mode: act-first (non-negotiable on strong signals)
|
||||
|
||||
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`file-editText\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
|
||||
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
|
||||
|
||||
What you must NOT do on a strong-signal ask:
|
||||
- Don't ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic doc editing, not live notes.
|
||||
|
|
@ -77,7 +77,7 @@ When a strong signal lands without a specific note attached, pick the folder by
|
|||
|
||||
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
|
||||
|
||||
**Before creating**: \`file-grep\` and \`file-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
|
||||
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
|
||||
|
||||
### Default cadence picker (when the user didn't specify timing)
|
||||
|
||||
|
|
@ -165,8 +165,8 @@ When skipping a re-run (because the user said not to or "later"):
|
|||
**User:** "i want to set up a news feed to track news for India and the world."
|
||||
|
||||
**Right behaviour** (one turn):
|
||||
1. \`file-grep({ pattern: "News Feed", searchPath: "knowledge/Notes/" })\` — search for an existing match.
|
||||
2. \`file-grep({ pattern: "news", searchPath: "knowledge/Notes/" })\` — broader search to catch variants.
|
||||
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
|
||||
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
|
||||
3. No match found → create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
|
||||
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
|
||||
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
|
||||
|
|
@ -460,16 +460,16 @@ live:
|
|||
|
||||
### Making a passive note live (no \`live:\` block yet)
|
||||
|
||||
1. \`file-readText({ path })\` — re-read fresh.
|
||||
1. \`workspace-readFile({ path })\` — re-read fresh.
|
||||
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
|
||||
3. \`file-editText\`:
|
||||
3. \`workspace-edit\`:
|
||||
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
|
||||
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
|
||||
|
||||
### Extending an already-live note
|
||||
|
||||
1. \`file-readText({ path })\` — fetch the current \`live.objective\`.
|
||||
2. Edit the \`objective\` value via \`file-editText\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
|
||||
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`.
|
||||
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
|
||||
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
|
@ -606,17 +606,17 @@ The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, err
|
|||
- **Don't add \`triggers\`** if the user explicitly wants manual-only.
|
||||
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't use \`file-writeText\`** to rewrite the whole file — always \`file-editText\` with a unique anchor.
|
||||
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Live Note
|
||||
|
||||
**Change the objective:** \`file-editText\` the \`objective\` value (use \`|\` block scalar).
|
||||
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar).
|
||||
|
||||
**Change triggers:** \`file-editText\` the relevant sub-field of the \`triggers\` object.
|
||||
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object.
|
||||
|
||||
**Pause without removing:** flip \`active: false\`.
|
||||
|
||||
**Make passive (remove the \`live:\` block):** \`file-editText\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
|
||||
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const skill = String.raw`
|
|||
|
||||
**ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors.
|
||||
|
||||
**NEVER manually create or edit \`config/mcp.json\`** using \`file-writeText\` for MCP servers—this bypasses validation and will cause errors.
|
||||
**NEVER manually create or edit \`config/mcp.json\`** using \`workspace-writeFile\` for MCP servers—this bypasses validation and will cause errors.
|
||||
|
||||
### MCP Server Configuration Schema
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ When the user asks to prep for a meeting or mentions attendees:
|
|||
1. **STOP** - Do not create a generic brief
|
||||
2. **SEARCH** - Look up each attendee in the knowledge base:
|
||||
\`\`\`
|
||||
file-grep({ pattern: "Attendee Name", searchPath: "knowledge/" })
|
||||
workspace-grep({ pattern: "Attendee Name", path: "knowledge/" })
|
||||
\`\`\`
|
||||
3. **READ** - Read their notes to understand who they are:
|
||||
\`\`\`
|
||||
file-readText("knowledge/People/Attendee Name.md")
|
||||
file-readText("knowledge/Organizations/Their Company.md")
|
||||
workspace-readFile("knowledge/People/Attendee Name.md")
|
||||
workspace-readFile("knowledge/Organizations/Their Company.md")
|
||||
\`\`\`
|
||||
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
|
||||
5. **THEN BRIEF** - Only now create the meeting brief, using this context
|
||||
|
|
@ -68,13 +68,13 @@ For each attendee, search the knowledge base (path MUST be \`knowledge/\`):
|
|||
|
||||
**Search People notes:**
|
||||
\`\`\`
|
||||
file-grep({ pattern: "attendee_name", searchPath: "knowledge/People/" })
|
||||
file-grep({ pattern: "attendee_email", searchPath: "knowledge/People/" })
|
||||
workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" })
|
||||
workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" })
|
||||
\`\`\`
|
||||
|
||||
If a person note exists, read it:
|
||||
\`\`\`
|
||||
file-readText("knowledge/People/Attendee Name.md")
|
||||
workspace-readFile("knowledge/People/Attendee Name.md")
|
||||
\`\`\`
|
||||
|
||||
Extract:
|
||||
|
|
@ -86,13 +86,13 @@ Extract:
|
|||
|
||||
**Search Organization notes:**
|
||||
\`\`\`
|
||||
file-grep({ pattern: "company_name", searchPath: "knowledge/Organizations/" })
|
||||
workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" })
|
||||
\`\`\`
|
||||
|
||||
**Search Projects:**
|
||||
\`\`\`
|
||||
file-grep({ pattern: "attendee_name", searchPath: "knowledge/Projects/" })
|
||||
file-grep({ pattern: "company_name", searchPath: "knowledge/Projects/" })
|
||||
workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" })
|
||||
workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" })
|
||||
\`\`\`
|
||||
|
||||
### Step 4: Create Meeting Brief
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ Use these as the \`link\` parameter to land the user on a specific view in Rowbo
|
|||
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
|
||||
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
|
||||
|
||||
The \`type=file\` path is workspace-relative (the same path you'd pass to \`file-readText\`).
|
||||
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
|
||||
|
||||
## Anti-patterns
|
||||
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { createReadStream, existsSync, readFileSync } from "fs";
|
||||
import { createInterface } from "readline";
|
||||
import { execSync } from "child_process";
|
||||
import { glob } from "glob";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
import { IMcpConfigRepo } from "../..//mcp/repo.js";
|
||||
import { McpServerDefinition } from "@x/shared/dist/mcp.js";
|
||||
import * as files from "../../filesystem/files.js";
|
||||
import * as workspace from "../../workspace/workspace.js";
|
||||
import { IAgentsRepo } from "../../agents/repo.js";
|
||||
import { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
|
|
@ -15,10 +19,6 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi
|
|||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
|
||||
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
|
||||
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
|
||||
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
|
||||
|
||||
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
|
||||
// descriptions; only `triggers` gets a tighter contextual override (the
|
||||
|
|
@ -93,6 +93,43 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
|
|||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE
|
||||
// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL).
|
||||
// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe
|
||||
// from the npm-shim layout and inject it via env so the bridge can spawn it.
|
||||
function resolveClaudeExeOnWindows(): string | undefined {
|
||||
const pathDirs = (process.env.PATH ?? '').split(';');
|
||||
for (const dir of pathDirs) {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) continue;
|
||||
const cmdPath = path.join(trimmed, 'claude.cmd');
|
||||
if (!existsSync(cmdPath)) continue;
|
||||
const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
if (existsSync(exeFromLayout)) return exeFromLayout;
|
||||
try {
|
||||
const content = readFileSync(cmdPath, 'utf-8');
|
||||
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
|
||||
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
|
||||
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
|
||||
if (relMatch) {
|
||||
const resolved = path.join(trimmed, relMatch[1]);
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
} catch {
|
||||
// ignore shim parse failures
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envForCommand(command: string): NodeJS.ProcessEnv | undefined {
|
||||
if (process.platform !== 'win32') return undefined;
|
||||
if (!/\bacpx\b/.test(command)) return undefined;
|
||||
if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined;
|
||||
const exe = resolveClaudeExeOnWindows();
|
||||
if (!exe) return undefined;
|
||||
return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe };
|
||||
}
|
||||
|
||||
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||
loadSkill: {
|
||||
|
|
@ -119,12 +156,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-getRoot': {
|
||||
description: 'Get the default root directory for relative file paths. Relative paths passed to file tools resolve against this directory.',
|
||||
'workspace-getRoot': {
|
||||
description: 'Get the workspace root directory path',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
return { root: WorkDir };
|
||||
return await workspace.getRoot();
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -133,14 +170,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-exists': {
|
||||
description: 'Check if a file or directory exists. Accepts absolute paths, ~/ paths, or paths relative to the default root.',
|
||||
'workspace-exists': {
|
||||
description: 'Check if a file or directory exists in the workspace',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('File or directory path to check'),
|
||||
path: z.string().min(1).describe('Workspace-relative path to check'),
|
||||
}),
|
||||
execute: async ({ path: filePath }: { path: string }) => {
|
||||
execute: async ({ path: relPath }: { path: string }) => {
|
||||
try {
|
||||
return await files.exists(filePath);
|
||||
return await workspace.exists(relPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -149,14 +186,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-stat': {
|
||||
'workspace-stat': {
|
||||
description: 'Get file or directory statistics (size, modification time, etc.)',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('File or directory path to stat'),
|
||||
path: z.string().min(1).describe('Workspace-relative path to stat'),
|
||||
}),
|
||||
execute: async ({ path: filePath }: { path: string }) => {
|
||||
execute: async ({ path: relPath }: { path: string }) => {
|
||||
try {
|
||||
return await files.stat(filePath);
|
||||
return await workspace.stat(relPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -165,22 +202,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-list': {
|
||||
'workspace-readdir': {
|
||||
description: 'List directory contents. Can recursively explore directory structure with options.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe('Directory path to list. Use "." for the default root.'),
|
||||
path: z.string().describe('Workspace-relative directory path (empty string for root)'),
|
||||
recursive: z.boolean().optional().describe('Recursively list all subdirectories (default: false)'),
|
||||
includeStats: z.boolean().optional().describe('Include file stats like size and modification time (default: false)'),
|
||||
includeHidden: z.boolean().optional().describe('Include hidden files starting with . (default: false)'),
|
||||
allowedExtensions: z.array(z.string()).optional().describe('Filter by file extensions (e.g., [".json", ".ts"])'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: filePath,
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
allowedExtensions
|
||||
}: {
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
allowedExtensions
|
||||
}: {
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
includeStats?: boolean;
|
||||
|
|
@ -188,12 +225,13 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
allowedExtensions?: string[];
|
||||
}) => {
|
||||
try {
|
||||
return await files.list(filePath || '.', {
|
||||
const entries = await workspace.readdir(relPath || '', {
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
allowedExtensions,
|
||||
});
|
||||
return entries;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -202,24 +240,120 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-readText': {
|
||||
description: 'Read a UTF-8 text file. Returns content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use `offset` and `limit` to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<resolvedPath>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers are display-only — do NOT include them when later writing or editing the file. Refuses binary files; use parseFile or LLMParse for documents, PDFs, images, and other non-text formats.',
|
||||
'workspace-readFile': {
|
||||
description: 'Read a file from the workspace. For text files (utf8, the default), returns the content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use the `offset` and `limit` parameters to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers in the output are display-only — do NOT include them when later writing or editing the file. For `base64` / `binary` encodings, returns the raw bytes as a string and ignores `offset` / `limit`.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Text file path to read'),
|
||||
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1).'),
|
||||
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000).'),
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'),
|
||||
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: filePath,
|
||||
path: relPath,
|
||||
offset,
|
||||
limit,
|
||||
encoding = 'utf8',
|
||||
}: {
|
||||
path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
encoding?: 'utf8' | 'base64' | 'binary';
|
||||
}) => {
|
||||
try {
|
||||
return await files.readText(filePath, offset, limit);
|
||||
if (encoding !== 'utf8') {
|
||||
return await workspace.readFile(relPath, encoding);
|
||||
}
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000;
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
|
||||
const MAX_BYTES = 50 * 1024;
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
|
||||
|
||||
const absPath = workspace.resolveWorkspacePath(relPath);
|
||||
const stats = await fs.lstat(absPath);
|
||||
const stat = workspace.statToSchema(stats, 'file');
|
||||
const etag = workspace.computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
const effectiveOffset = offset ?? 1;
|
||||
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
|
||||
const start = effectiveOffset - 1;
|
||||
|
||||
const stream = createReadStream(absPath, { encoding: 'utf8' });
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
const collected: string[] = [];
|
||||
let totalLines = 0;
|
||||
let bytes = 0;
|
||||
let truncatedByBytes = false;
|
||||
let hasMoreLines = false;
|
||||
|
||||
try {
|
||||
for await (const text of rl) {
|
||||
totalLines += 1;
|
||||
if (totalLines <= start) continue;
|
||||
|
||||
if (collected.length >= effectiveLimit) {
|
||||
hasMoreLines = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH
|
||||
? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
|
||||
: text;
|
||||
const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0);
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
truncatedByBytes = true;
|
||||
hasMoreLines = true;
|
||||
break;
|
||||
}
|
||||
|
||||
collected.push(line);
|
||||
bytes += size;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) {
|
||||
return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` };
|
||||
}
|
||||
|
||||
const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`);
|
||||
const lastReadLine = effectiveOffset + collected.length - 1;
|
||||
const nextOffset = lastReadLine + 1;
|
||||
|
||||
let footer: string;
|
||||
if (truncatedByBytes) {
|
||||
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`;
|
||||
} else if (hasMoreLines) {
|
||||
footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`;
|
||||
} else {
|
||||
footer = `(End of file - total ${totalLines} lines)`;
|
||||
}
|
||||
|
||||
const content = [
|
||||
`<path>${relPath}</path>`,
|
||||
`<type>file</type>`,
|
||||
`<content>`,
|
||||
prefixed.join('\n'),
|
||||
'',
|
||||
footer,
|
||||
`</content>`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
path: relPath,
|
||||
encoding: 'utf8' as const,
|
||||
content,
|
||||
stat,
|
||||
etag,
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
totalLines,
|
||||
hasMore: hasMoreLines || truncatedByBytes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -228,30 +362,34 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-writeText': {
|
||||
description: 'Write or update UTF-8 text file contents. Automatically creates parent directories and supports atomic writes.',
|
||||
'workspace-writeFile': {
|
||||
description: 'Write or update file contents in the workspace. Automatically creates parent directories and supports atomic writes.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Text file path to write'),
|
||||
data: z.string().describe('UTF-8 text content to write'),
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
data: z.string().describe('File content to write'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('Data encoding (default: utf8)'),
|
||||
atomic: z.boolean().optional().describe('Use atomic write (default: true)'),
|
||||
mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
|
||||
expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: filePath,
|
||||
path: relPath,
|
||||
data,
|
||||
encoding,
|
||||
atomic,
|
||||
mkdirp,
|
||||
expectedEtag
|
||||
}: {
|
||||
path: string;
|
||||
data: string;
|
||||
encoding?: 'utf8' | 'base64' | 'binary';
|
||||
atomic?: boolean;
|
||||
mkdirp?: boolean;
|
||||
expectedEtag?: string;
|
||||
}) => {
|
||||
try {
|
||||
return await files.writeText(filePath, data, {
|
||||
return await workspace.writeFile(relPath, data, {
|
||||
encoding,
|
||||
atomic,
|
||||
mkdirp,
|
||||
expectedEtag,
|
||||
|
|
@ -264,16 +402,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-editText': {
|
||||
description: 'Make precise edits to a UTF-8 text file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss. Refuses binary files.',
|
||||
'workspace-edit': {
|
||||
description: 'Make precise edits to a file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Text file path to edit'),
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
oldString: z.string().describe('Exact text to find and replace'),
|
||||
newString: z.string().describe('Replacement text'),
|
||||
replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: filePath,
|
||||
path: relPath,
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll = false
|
||||
|
|
@ -284,22 +422,46 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
replaceAll?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
return await files.editText(filePath, oldString, newString, replaceAll);
|
||||
const result = await workspace.readFile(relPath, 'utf8');
|
||||
const content = result.data;
|
||||
|
||||
const occurrences = content.split(oldString).length - 1;
|
||||
|
||||
if (occurrences === 0) {
|
||||
return { error: 'oldString not found in file' };
|
||||
}
|
||||
|
||||
if (occurrences > 1 && !replaceAll) {
|
||||
return {
|
||||
error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.`
|
||||
};
|
||||
}
|
||||
|
||||
const newContent = replaceAll
|
||||
? content.replaceAll(oldString, newString)
|
||||
: content.replace(oldString, newString);
|
||||
|
||||
await workspace.writeFile(relPath, newContent, { encoding: 'utf8' });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
replacements: replaceAll ? occurrences : 1
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'file-mkdir': {
|
||||
description: 'Create a directory',
|
||||
'workspace-mkdir': {
|
||||
description: 'Create a directory in the workspace',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Directory path to create'),
|
||||
path: z.string().min(1).describe('Workspace-relative directory path'),
|
||||
recursive: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
|
||||
}),
|
||||
execute: async ({ path: filePath, recursive = true }: { path: string; recursive?: boolean }) => {
|
||||
execute: async ({ path: relPath, recursive = true }: { path: string; recursive?: boolean }) => {
|
||||
try {
|
||||
return await files.mkdir(filePath, recursive);
|
||||
return await workspace.mkdir(relPath, recursive);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -308,16 +470,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-rename': {
|
||||
description: 'Rename or move a file or directory',
|
||||
'workspace-rename': {
|
||||
description: 'Rename or move a file or directory in the workspace',
|
||||
inputSchema: z.object({
|
||||
from: z.string().min(1).describe('Source path'),
|
||||
to: z.string().min(1).describe('Destination path'),
|
||||
from: z.string().min(1).describe('Source workspace-relative path'),
|
||||
to: z.string().min(1).describe('Destination workspace-relative path'),
|
||||
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
|
||||
}),
|
||||
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
|
||||
try {
|
||||
return await files.rename(from, to, overwrite);
|
||||
return await workspace.rename(from, to, overwrite);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -326,16 +488,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-copy': {
|
||||
description: 'Copy a file (directories not supported)',
|
||||
'workspace-copy': {
|
||||
description: 'Copy a file in the workspace (directories not supported)',
|
||||
inputSchema: z.object({
|
||||
from: z.string().min(1).describe('Source file path'),
|
||||
to: z.string().min(1).describe('Destination file path'),
|
||||
from: z.string().min(1).describe('Source workspace-relative file path'),
|
||||
to: z.string().min(1).describe('Destination workspace-relative file path'),
|
||||
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
|
||||
}),
|
||||
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
|
||||
try {
|
||||
return await files.copy(from, to, overwrite);
|
||||
return await workspace.copy(from, to, overwrite);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -344,16 +506,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-remove': {
|
||||
description: 'Remove a file or directory. Files are moved to the Rowboat trash by default for safety.',
|
||||
'workspace-remove': {
|
||||
description: 'Remove a file or directory from the workspace. Files are moved to trash by default for safety.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Path to remove'),
|
||||
path: z.string().min(1).describe('Workspace-relative path to remove'),
|
||||
recursive: z.boolean().optional().describe('Required for directories (default: false)'),
|
||||
trash: z.boolean().optional().describe('Move to trash instead of permanent delete (default: true)'),
|
||||
}),
|
||||
execute: async ({ path: filePath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => {
|
||||
execute: async ({ path: relPath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => {
|
||||
try {
|
||||
return await files.remove(filePath, {
|
||||
return await workspace.remove(relPath, {
|
||||
recursive,
|
||||
trash,
|
||||
});
|
||||
|
|
@ -365,26 +527,45 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'file-glob': {
|
||||
'workspace-glob': {
|
||||
description: 'Find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.json"). Much faster than recursive readdir for finding files.',
|
||||
inputSchema: z.object({
|
||||
pattern: z.string().describe('Glob pattern to match files'),
|
||||
cwd: z.string().optional().describe('Directory to search in (default: default root)'),
|
||||
cwd: z.string().optional().describe('Subdirectory to search in, relative to workspace root (default: workspace root)'),
|
||||
}),
|
||||
execute: async ({ pattern, cwd }: { pattern: string; cwd?: string }) => {
|
||||
try {
|
||||
return await files.glob(pattern, cwd);
|
||||
const searchDir = cwd ? path.join(WorkDir, cwd) : WorkDir;
|
||||
|
||||
// Ensure search directory is within workspace
|
||||
const resolvedSearchDir = path.resolve(searchDir);
|
||||
if (!resolvedSearchDir.startsWith(WorkDir)) {
|
||||
return { error: 'Search directory must be within workspace' };
|
||||
}
|
||||
|
||||
const files = await glob(pattern, {
|
||||
cwd: searchDir,
|
||||
nodir: true,
|
||||
ignore: ['node_modules/**', '.git/**'],
|
||||
});
|
||||
|
||||
return {
|
||||
files,
|
||||
count: files.length,
|
||||
pattern,
|
||||
cwd: cwd || '.',
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'file-grep': {
|
||||
description: 'Search text file contents using regex. Returns matching files and lines. Skips binary files.',
|
||||
'workspace-grep': {
|
||||
description: 'Search file contents using regex. Returns matching files and lines. Uses ripgrep if available, falls back to grep.',
|
||||
inputSchema: z.object({
|
||||
pattern: z.string().describe('Regex pattern to search for'),
|
||||
searchPath: z.string().optional().describe('Directory or file to search (default: default root)'),
|
||||
searchPath: z.string().optional().describe('Directory or file to search, relative to workspace root (default: workspace root)'),
|
||||
fileGlob: z.string().optional().describe('File pattern filter (e.g., "*.ts", "*.md")'),
|
||||
contextLines: z.number().optional().describe('Lines of context around matches (default: 0)'),
|
||||
maxResults: z.number().optional().describe('Maximum results to return (default: 100)'),
|
||||
|
|
@ -403,7 +584,90 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
maxResults?: number;
|
||||
}) => {
|
||||
try {
|
||||
return await files.grep({ pattern, searchPath, fileGlob, contextLines, maxResults });
|
||||
const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir;
|
||||
|
||||
// Ensure target path is within workspace
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
if (!resolvedTargetPath.startsWith(WorkDir)) {
|
||||
return { error: 'Search path must be within workspace' };
|
||||
}
|
||||
|
||||
// Try ripgrep first
|
||||
try {
|
||||
const rgArgs = [
|
||||
'--json',
|
||||
'-e', JSON.stringify(pattern),
|
||||
contextLines > 0 ? `-C ${contextLines}` : '',
|
||||
fileGlob ? `--glob ${JSON.stringify(fileGlob)}` : '',
|
||||
`--max-count ${maxResults}`,
|
||||
'--ignore-case',
|
||||
JSON.stringify(resolvedTargetPath),
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const output = execSync(`rg ${rgArgs}`, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
cwd: WorkDir,
|
||||
});
|
||||
|
||||
const matches = output.trim().split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(m => m && m.type === 'match');
|
||||
|
||||
return {
|
||||
matches: matches.map(m => ({
|
||||
file: path.relative(WorkDir, m.data.path.text),
|
||||
line: m.data.line_number,
|
||||
content: m.data.lines.text.trim(),
|
||||
})),
|
||||
count: matches.length,
|
||||
tool: 'ripgrep',
|
||||
};
|
||||
} catch {
|
||||
// Fallback to basic grep if ripgrep not available or failed
|
||||
const grepArgs = [
|
||||
'-rn',
|
||||
fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '',
|
||||
JSON.stringify(pattern),
|
||||
JSON.stringify(resolvedTargetPath),
|
||||
`| head -${maxResults}`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
try {
|
||||
const output = execSync(`grep ${grepArgs}`, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
shell: '/bin/sh',
|
||||
});
|
||||
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
return {
|
||||
matches: lines.map(line => {
|
||||
const match = line.match(/^(.+?):(\d+):(.*)$/);
|
||||
if (match) {
|
||||
return {
|
||||
file: path.relative(WorkDir, match[1]),
|
||||
line: parseInt(match[2], 10),
|
||||
content: match[3].trim(),
|
||||
};
|
||||
}
|
||||
return { file: '', line: 0, content: line };
|
||||
}),
|
||||
count: lines.length,
|
||||
tool: 'grep',
|
||||
};
|
||||
} catch {
|
||||
// No matches found (grep returns non-zero on no matches)
|
||||
return { matches: [], count: 0, tool: 'grep' };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
|
@ -413,7 +677,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
'parseFile': {
|
||||
description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'),
|
||||
path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'),
|
||||
}),
|
||||
execute: async ({ path: filePath }: { path: string }) => {
|
||||
try {
|
||||
|
|
@ -428,7 +692,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
};
|
||||
}
|
||||
|
||||
const { buffer, resolvedPath } = await files.readBuffer(filePath);
|
||||
// Read file as buffer — support both absolute and workspace-relative paths
|
||||
let buffer: Buffer;
|
||||
if (path.isAbsolute(filePath)) {
|
||||
buffer = await fs.readFile(filePath);
|
||||
} else {
|
||||
const result = await workspace.readFile(filePath, 'base64');
|
||||
buffer = Buffer.from(result.data, 'base64');
|
||||
}
|
||||
|
||||
if (ext === '.pdf') {
|
||||
const { PDFParse } = await _importDynamic("pdf-parse");
|
||||
|
|
@ -445,7 +716,6 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
pages: textResult.total,
|
||||
title: infoResult.info?.Title || undefined,
|
||||
author: infoResult.info?.Author || undefined,
|
||||
resolvedPath,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
|
|
@ -515,7 +785,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
'LLMParse': {
|
||||
description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'),
|
||||
path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'),
|
||||
prompt: z.string().optional().describe('Custom instruction for the LLM (defaults to "Convert this file to well-structured markdown.")'),
|
||||
}),
|
||||
execute: async ({ path: filePath, prompt }: { path: string; prompt?: string }) => {
|
||||
|
|
@ -531,7 +801,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
};
|
||||
}
|
||||
|
||||
const { buffer } = await files.readBuffer(filePath);
|
||||
// Read file as buffer — support both absolute and workspace-relative paths
|
||||
let buffer: Buffer;
|
||||
if (path.isAbsolute(filePath)) {
|
||||
buffer = await fs.readFile(filePath);
|
||||
} else {
|
||||
const result = await workspace.readFile(filePath, 'base64');
|
||||
buffer = Buffer.from(result.data, 'base64');
|
||||
}
|
||||
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
|
|
@ -754,11 +1031,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// };
|
||||
// }
|
||||
|
||||
const envOverride = envForCommand(command);
|
||||
|
||||
// Use abortable version when we have a signal
|
||||
if (ctx?.signal) {
|
||||
const { promise, process: proc } = executeCommandAbortable(command, {
|
||||
cwd: workingDir,
|
||||
signal: ctx.signal,
|
||||
env: envOverride,
|
||||
onData: (chunk: string) => {
|
||||
ctx.publish({
|
||||
runId: ctx.runId,
|
||||
|
|
@ -808,104 +1088,6 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
code_agent_run: {
|
||||
description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.',
|
||||
inputSchema: z.object({
|
||||
agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'),
|
||||
cwd: z.string().describe('Absolute path to the working directory / project folder the agent should operate in.'),
|
||||
prompt: z.string().describe('The full, self-contained coding instruction for the agent (file names, expected behavior, constraints).'),
|
||||
}),
|
||||
execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => {
|
||||
if (!ctx) {
|
||||
return { success: false, message: 'code_agent_run requires run context (runId / streaming).' };
|
||||
}
|
||||
// The composer chip is the source of truth for the agent. The model's `agent`
|
||||
// argument is only a fallback for the ask-human flow (code mode not active, no
|
||||
// chip set) — otherwise it can anchor on the thread's earlier agent and ignore a
|
||||
// chip change. Honor the chip so switching it deterministically switches agents.
|
||||
const effectiveAgent = ctx.codeMode ?? agent;
|
||||
const manager = container.resolve<CodeModeManager>('codeModeManager');
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
|
||||
// Approval policy from settings; default to asking the user.
|
||||
let policy: ApprovalPolicy = 'ask';
|
||||
try {
|
||||
const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig();
|
||||
if (cfg.approvalPolicy) policy = cfg.approvalPolicy;
|
||||
} catch {
|
||||
// fall back to 'ask'
|
||||
}
|
||||
|
||||
// On stop, unblock any pending approval card so the broker stops waiting for
|
||||
// an answer that will never come. The ACP cancel + force-kill backstop that
|
||||
// actually ends the turn is handled inside manager.runPrompt via the signal
|
||||
// we pass below.
|
||||
const onAbort = () => registry.cancelRun(ctx.runId);
|
||||
if (ctx.signal.aborted) onAbort();
|
||||
else ctx.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
let finalText = '';
|
||||
const changedFiles = new Set<string>();
|
||||
try {
|
||||
const result = await manager.runPrompt({
|
||||
runId: ctx.runId,
|
||||
agent: effectiveAgent,
|
||||
cwd,
|
||||
prompt,
|
||||
policy,
|
||||
signal: ctx.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
|
||||
if (event.type === 'tool_call_update') for (const f of event.diffs) changedFiles.add(f);
|
||||
void ctx.publish({
|
||||
runId: ctx.runId,
|
||||
type: 'code-run-event',
|
||||
toolCallId: ctx.toolCallId,
|
||||
event,
|
||||
subflow: [],
|
||||
});
|
||||
},
|
||||
ask: (permAsk) => registry.request(ctx.runId, (requestId) => {
|
||||
void ctx.publish({
|
||||
runId: ctx.runId,
|
||||
type: 'code-run-permission-request',
|
||||
toolCallId: ctx.toolCallId,
|
||||
requestId,
|
||||
ask: permAsk,
|
||||
subflow: [],
|
||||
});
|
||||
}),
|
||||
});
|
||||
return {
|
||||
success: result.stopReason === 'end_turn',
|
||||
stopReason: result.stopReason,
|
||||
// The agent that actually ran (the chip), so the UI can label the run
|
||||
// authoritatively rather than trusting the model's `agent` argument.
|
||||
agent: effectiveAgent,
|
||||
summary: finalText.trim(),
|
||||
changedFiles: [...changedFiles],
|
||||
};
|
||||
} catch (error) {
|
||||
// A stop mid-run isn't a failure — report it as a clean cancellation.
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
stopReason: 'cancelled',
|
||||
agent: effectiveAgent,
|
||||
summary: finalText.trim(),
|
||||
changedFiles: [...changedFiles],
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
} finally {
|
||||
ctx.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Browser Skills (browser-use/browser-harness domain-skills cache)
|
||||
// ============================================================================
|
||||
|
|
@ -1046,7 +1228,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
case 'open-note': {
|
||||
const filePath = input.path as string;
|
||||
try {
|
||||
const result = await files.exists(filePath);
|
||||
const result = await workspace.exists(filePath);
|
||||
if (!result.exists) {
|
||||
return { success: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
|
|
@ -1074,15 +1256,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// Scan knowledge/ files and extract frontmatter properties
|
||||
try {
|
||||
const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js");
|
||||
const entries = await files.list("knowledge", { recursive: true, allowedExtensions: [".md"] });
|
||||
const noteFiles = entries.filter(e => e.kind === 'file');
|
||||
const entries = await workspace.readdir("knowledge", { recursive: true, allowedExtensions: [".md"] });
|
||||
const files = entries.filter(e => e.kind === 'file');
|
||||
const properties = new Map<string, Set<string>>();
|
||||
let noteCount = 0;
|
||||
|
||||
for (const file of noteFiles) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const result = await fs.readFile(file.resolvedPath, 'utf8');
|
||||
const { fields } = parseFrontmatter(result);
|
||||
const { data } = await workspace.readFile(file.path);
|
||||
const { fields } = parseFrontmatter(data);
|
||||
noteCount++;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!value) continue;
|
||||
|
|
@ -1127,7 +1309,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
const basePath = `bases/${safeName}.base`;
|
||||
try {
|
||||
const config = { name: safeName, filters: [], columns: [] };
|
||||
await files.writeText(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
|
||||
await workspace.writeFile(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
|
||||
return { success: true, action: 'create-base', name: safeName, path: basePath };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
@ -1473,7 +1655,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
'create-background-task': {
|
||||
description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with file-editText, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks/<slug>/{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.",
|
||||
description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with workspace-edit, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks/<slug>/{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.",
|
||||
inputSchema: CreateBackgroundTaskInput,
|
||||
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
|
||||
try {
|
||||
|
|
@ -1493,7 +1675,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
'patch-background-task': {
|
||||
description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `file-glob` on `bg-tasks/*/task.yaml` and `file-readText` on the candidates first.",
|
||||
description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `workspace-glob` on `bg-tasks/*/task.yaml` and `workspace-readFile` on the candidates first.",
|
||||
inputSchema: PatchBackgroundTaskInput,
|
||||
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ export async function executeCommand(
|
|||
cwd?: string;
|
||||
timeout?: number; // timeout in milliseconds
|
||||
maxBuffer?: number; // max buffer size in bytes
|
||||
env?: NodeJS.ProcessEnv; // override environment
|
||||
}
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
|
|
@ -90,7 +89,6 @@ export async function executeCommand(
|
|||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||
shell,
|
||||
env: options?.env,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ export interface ToolContext {
|
|||
signal: AbortSignal;
|
||||
abortRegistry: IAbortRegistry;
|
||||
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
|
||||
// The composer code-mode chip for the message that triggered this turn. When set,
|
||||
// it is the authoritative coding agent — code_agent_run uses it rather than the
|
||||
// agent the model guessed, so switching the chip deterministically switches agents.
|
||||
codeMode?: 'claude' | 'codex' | null;
|
||||
}
|
||||
|
||||
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
|
|||
|
|
@ -8,20 +8,17 @@ export type MiddlePaneContext =
|
|||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string };
|
||||
|
||||
export type CodeMode = 'claude' | 'codex';
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: UserMessageContentType;
|
||||
voiceInput?: boolean;
|
||||
voiceOutput?: VoiceOutputMode;
|
||||
searchEnabled?: boolean;
|
||||
codeMode?: CodeMode;
|
||||
middlePaneContext?: MiddlePaneContext;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +34,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
@ -48,7 +45,6 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
voiceInput,
|
||||
voiceOutput,
|
||||
searchEnabled,
|
||||
codeMode,
|
||||
middlePaneContext,
|
||||
});
|
||||
return id;
|
||||
|
|
|
|||
|
|
@ -3,21 +3,14 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import { ClientRegistrationResponse } from './types.js';
|
||||
|
||||
export const DEFAULT_CALLBACK_PORT = 8080;
|
||||
|
||||
export interface IClientRegistrationRepo {
|
||||
getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null>;
|
||||
/** Returns the port that was used when DCR-registering this provider, or DEFAULT_CALLBACK_PORT if not stored. */
|
||||
getRegisteredPort(provider: string): Promise<number>;
|
||||
saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void>;
|
||||
saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void>;
|
||||
clearClientRegistration(provider: string): Promise<void>;
|
||||
}
|
||||
|
||||
// _registeredPort is our private field — stripped by Zod when we parse the RFC response fields
|
||||
type StoredEntry = Record<string, unknown> & { _registeredPort?: number };
|
||||
|
||||
type ClientRegistrationStorage = {
|
||||
[provider: string]: StoredEntry;
|
||||
[provider: string]: ClientRegistrationResponse;
|
||||
};
|
||||
|
||||
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
||||
|
|
@ -52,14 +45,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
|||
|
||||
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
|
||||
const config = await this.readConfig();
|
||||
const entry = config[provider];
|
||||
if (!entry) {
|
||||
const registration = config[provider];
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate registration structure (Zod strips unknown fields like _registeredPort)
|
||||
// Validate registration structure
|
||||
try {
|
||||
return ClientRegistrationResponse.parse(entry);
|
||||
return ClientRegistrationResponse.parse(registration);
|
||||
} catch {
|
||||
// Invalid registration, remove it
|
||||
await this.clearClientRegistration(provider);
|
||||
|
|
@ -67,14 +60,9 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
|||
}
|
||||
}
|
||||
|
||||
async getRegisteredPort(provider: string): Promise<number> {
|
||||
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void> {
|
||||
const config = await this.readConfig();
|
||||
return config[provider]?._registeredPort ?? DEFAULT_CALLBACK_PORT;
|
||||
}
|
||||
|
||||
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void> {
|
||||
const config = await this.readConfig();
|
||||
config[provider] = { ...registration, _registeredPort: port };
|
||||
config[provider] = registration;
|
||||
await this.writeConfig(config);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,25 +26,6 @@ export class ReconnectRequiredError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the api signals a transient failure (rate limit, in-flight dedup,
|
||||
* upstream 5xx) — caller should leave stored tokens untouched and retry on its
|
||||
* next tick rather than flagging the user for reconnect.
|
||||
*
|
||||
* In particular: the backend returns 429 with `Refresh in progress, retry shortly`
|
||||
* when two desktop clients race the same refresh; the proactive in-flight dedup
|
||||
* in GoogleClientFactory should make that unreachable, but this is the safety
|
||||
* net if it ever isn't.
|
||||
*/
|
||||
export class TransientRefreshError extends Error {
|
||||
readonly status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "TransientRefreshError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
|
|
@ -123,17 +104,6 @@ export async function refreshTokensViaBackend(
|
|||
}
|
||||
throw new Error(`refresh failed: 409 ${err.error ?? ""}`.trim());
|
||||
}
|
||||
// 429 = backend dedup said another refresh is in flight; 5xx = upstream
|
||||
// hiccup. Either way the local tokens are still valid for the next attempt
|
||||
// — surface as TransientRefreshError so the factory doesn't write a stuck
|
||||
// error into oauth.json.
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
const err = await readError(res);
|
||||
throw new TransientRefreshError(
|
||||
`refresh failed: ${res.status} ${err.error ?? ""}`.trim(),
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await readError(res);
|
||||
throw new Error(`refresh failed: ${res.status} ${err.error ?? ""}`.trim());
|
||||
|
|
|
|||
|
|
@ -75,8 +75,9 @@ const providerConfigs: ProviderConfig = {
|
|||
mode: 'static',
|
||||
},
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
'https://www.googleapis.com/auth/drive.readonly',
|
||||
],
|
||||
},
|
||||
'fireflies-ai': {
|
||||
|
|
@ -118,3 +119,4 @@ export async function getProviderConfig(providerName: string): Promise<ProviderC
|
|||
export function getAvailableProviders(): string[] {
|
||||
return Object.keys(providerConfigs);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ You are running with **no user present** to clarify, approve, or watch.
|
|||
|
||||
Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains:
|
||||
- \`task.yaml\` — the spec. **Never touch this.** The runtime owns it.
|
||||
- \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`.
|
||||
- \`index.md\` — agent-owned. You read and write this freely via \`workspace-readFile\` / \`workspace-edit\`.
|
||||
- \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does.
|
||||
|
||||
You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it.
|
||||
|
|
@ -26,7 +26,7 @@ OUTPUT MODE — keep \`index.md\` aligned to the instructions.
|
|||
Use when instructions imply a **current state** artifact:
|
||||
- "Maintain / show / summarize / track / digest of / dashboard for / brief on …"
|
||||
- "Keep me posted on …" / "What's the latest on …"
|
||||
On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
|
||||
On every run: \`workspace-readFile\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`workspace-edit\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
|
||||
|
||||
ACTION MODE — perform a side-effect, append a journal entry.
|
||||
Use when instructions imply a **recurring action**:
|
||||
|
|
@ -71,9 +71,7 @@ The workspace lives at \`${WorkDir}\`.
|
|||
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
// code_agent_run requires an interactive UI for permission approvals — skip it
|
||||
// here (headless) so it can't hang on an approval no one can answer.
|
||||
if (name === 'executeCommand' || name === 'code_agent_run') continue;
|
||||
if (name === 'executeCommand') continue;
|
||||
tools[name] = { type: 'builtin', name };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function buildMessage(
|
|||
**Instructions:**
|
||||
${task.instructions}
|
||||
|
||||
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
|
||||
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`workspace-readFile\` and update it with \`workspace-edit\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
|
||||
|
||||
return baseMessage + buildTriggerBlock({
|
||||
trigger,
|
||||
|
|
|
|||
|
|
@ -20,17 +20,8 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
status: string | null;
|
||||
trialExpiresAt: string | null;
|
||||
usage: {
|
||||
monthly: {
|
||||
sanctionedCredits: number;
|
||||
usedCredits: number;
|
||||
availableCredits: number;
|
||||
};
|
||||
daily: {
|
||||
sanctionedCredits: number;
|
||||
usedCredits: number;
|
||||
availableCredits: number;
|
||||
usageDay: string;
|
||||
};
|
||||
sanctionedCredits: number;
|
||||
availableCredits: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -40,7 +31,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
subscriptionPlan: body.billing.plan,
|
||||
subscriptionStatus: body.billing.status,
|
||||
trialExpiresAt: body.billing.trialExpiresAt ?? null,
|
||||
monthly: body.billing.usage.monthly,
|
||||
daily: body.billing.usage.daily,
|
||||
sanctionedCredits: body.billing.usage.sanctionedCredits,
|
||||
availableCredits: body.billing.usage.availableCredits,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import { createRequire } from 'module';
|
||||
import * as path from 'path';
|
||||
import type { CodingAgent } from './types.js';
|
||||
import { resolveClaudeExecutable } from './claude-exec.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// The ACP adapter npm package that exposes each coding agent as an ACP server.
|
||||
const ADAPTER_PACKAGE: Record<CodingAgent, string> = {
|
||||
claude: '@agentclientprotocol/claude-agent-acp',
|
||||
codex: '@agentclientprotocol/codex-acp',
|
||||
};
|
||||
|
||||
export interface AgentLaunchSpec {
|
||||
/** Executable to spawn — always `node` so we never hit the Windows .cmd EINVAL. */
|
||||
command: string;
|
||||
/** Args = [adapter entry script]. */
|
||||
args: string[];
|
||||
/** Extra env merged over process.env (e.g. CLAUDE_CODE_EXECUTABLE on Windows). */
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an
|
||||
// absolute path so we can spawn it directly with `node <entry>`. createRequire lets
|
||||
// us resolve workspace/pnpm-installed packages from this module's location.
|
||||
function resolveAdapterEntry(pkg: string): string {
|
||||
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
||||
const pkgDir = path.dirname(pkgJsonPath);
|
||||
const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> };
|
||||
const bin = pkgJson.bin;
|
||||
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
|
||||
if (!rel) {
|
||||
throw new Error(`ACP adapter ${pkg} has no bin entry to spawn`);
|
||||
}
|
||||
return path.join(pkgDir, rel);
|
||||
}
|
||||
|
||||
export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec {
|
||||
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
|
||||
// Point the Claude adapter at the real claude executable. On Windows this is
|
||||
// mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a
|
||||
// PATH safety net for GUI launches. Resolver is a no-op when claude isn't found,
|
||||
// leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire
|
||||
// an equivalent when we add Codex support.)
|
||||
if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) {
|
||||
const exe = resolveClaudeExecutable();
|
||||
if (exe) env.CLAUDE_CODE_EXECUTABLE = exe;
|
||||
}
|
||||
|
||||
// We spawn the adapter with process.execPath. Inside Electron's main process
|
||||
// that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make
|
||||
// it behave as a plain Node runtime. (Harmless under a real node process, which
|
||||
// ignores the var.) Without this the child never runs as node and the ACP stdio
|
||||
// stream closes immediately ("ACP connection closed").
|
||||
env.ELECTRON_RUN_AS_NODE = '1';
|
||||
|
||||
return { command: process.execPath, args: [entry], env };
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { commonInstallPaths } from '../status.js';
|
||||
|
||||
// Windows-only: Node refuses to spawn `.cmd` files without `shell: true` (EINVAL),
|
||||
// and the Claude ACP adapter spawns its executable directly. So we pre-resolve
|
||||
// claude's real `.exe` from the npm-shim layout. Used by resolveClaudeExecutable below.
|
||||
export function resolveClaudeExeOnWindows(): string | undefined {
|
||||
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
|
||||
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
|
||||
// includes them, which would otherwise leave us unable to find claude.exe and
|
||||
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
|
||||
const home = process.env.USERPROFILE ?? '';
|
||||
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
|
||||
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
const knownDirs = [
|
||||
appData && path.join(appData, 'npm'),
|
||||
localAppData && path.join(localAppData, 'npm'),
|
||||
appData && path.join(appData, 'pnpm'),
|
||||
localAppData && path.join(localAppData, 'pnpm'),
|
||||
home && path.join(home, '.volta', 'bin'),
|
||||
path.join(programFiles, 'nodejs'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
|
||||
const key = d.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const dir of candidates) {
|
||||
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
|
||||
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
if (existsSync(exeFromLayout)) return exeFromLayout;
|
||||
|
||||
// Otherwise parse the claude.cmd shim for the real exe path.
|
||||
const cmdPath = path.join(dir, 'claude.cmd');
|
||||
if (!existsSync(cmdPath)) continue;
|
||||
try {
|
||||
const content = readFileSync(cmdPath, 'utf-8');
|
||||
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
|
||||
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
|
||||
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
|
||||
if (relMatch) {
|
||||
const resolved = path.join(dir, relMatch[1]);
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
} catch {
|
||||
// ignore shim parse failures
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// macOS/Linux: find the real `claude` binary. Unlike Windows this isn't a spawn
|
||||
// requirement (no .cmd problem) — it's a PATH safety net. Electron apps launched
|
||||
// from the GUI (Dock/Finder) often don't inherit the login shell's PATH, so the
|
||||
// spawned adapter may fail to find `claude`. We resolve the path here so the adapter
|
||||
// can be pointed straight at it.
|
||||
function resolveClaudeBinaryUnix(): string | undefined {
|
||||
// Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …).
|
||||
try {
|
||||
const out = execSync("/bin/sh -lc 'command -v claude'", { timeout: 5000, encoding: 'utf-8' }).trim();
|
||||
if (out && existsSync(out)) return out;
|
||||
} catch {
|
||||
// not found on the login-shell PATH
|
||||
}
|
||||
// Fallback: scan well-known install locations directly.
|
||||
for (const candidate of commonInstallPaths('claude')) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cached: string | undefined;
|
||||
|
||||
// Cross-platform: the real `claude` executable to hand the ACP adapter via
|
||||
// CLAUDE_CODE_EXECUTABLE (the adapter prefers this env var on every OS). Returns
|
||||
// undefined if it can't be found — callers then fall back to the adapter's own lookup.
|
||||
// Cached on first success so we don't re-probe the shell on every cold start.
|
||||
export function resolveClaudeExecutable(): string | undefined {
|
||||
if (cached) return cached;
|
||||
const resolved = process.platform === 'win32' ? resolveClaudeExeOnWindows() : resolveClaudeBinaryUnix();
|
||||
if (resolved) cached = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { Writable, Readable } from 'node:stream';
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type SessionUpdate,
|
||||
type PromptResponse,
|
||||
type ReadTextFileRequest,
|
||||
type ReadTextFileResponse,
|
||||
type WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { CodingAgent, CodeRunEvent } from './types.js';
|
||||
import type { PermissionBroker } from './permission-broker.js';
|
||||
import { getAgentLaunchSpec } from './agents.js';
|
||||
|
||||
export interface AcpClientOptions {
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
broker: PermissionBroker;
|
||||
onEvent: (event: CodeRunEvent) => void;
|
||||
}
|
||||
|
||||
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
|
||||
function toEvent(update: SessionUpdate): CodeRunEvent {
|
||||
switch (update.sessionUpdate) {
|
||||
case 'agent_message_chunk':
|
||||
case 'user_message_chunk': {
|
||||
const c = update.content;
|
||||
const role = update.sessionUpdate === 'user_message_chunk' ? 'user' : 'agent';
|
||||
return { type: 'message', role, text: c.type === 'text' ? c.text : `[${c.type}]` };
|
||||
}
|
||||
case 'agent_thought_chunk':
|
||||
return { type: 'thought' };
|
||||
case 'tool_call':
|
||||
return {
|
||||
type: 'tool_call',
|
||||
id: update.toolCallId,
|
||||
title: update.title,
|
||||
kind: update.kind ?? undefined,
|
||||
status: update.status ?? undefined,
|
||||
};
|
||||
case 'tool_call_update': {
|
||||
const diffs = (update.content ?? [])
|
||||
.filter((c): c is Extract<typeof c, { type: 'diff' }> => c.type === 'diff')
|
||||
.map((c) => c.path);
|
||||
return { type: 'tool_call_update', id: update.toolCallId, status: update.status ?? undefined, diffs };
|
||||
}
|
||||
case 'plan':
|
||||
return {
|
||||
type: 'plan',
|
||||
entries: (update.entries ?? []).map((e) => ({
|
||||
content: e.content,
|
||||
status: e.status ?? undefined,
|
||||
priority: e.priority ?? undefined,
|
||||
})),
|
||||
};
|
||||
default:
|
||||
return { type: 'other', sessionUpdate: update.sessionUpdate };
|
||||
}
|
||||
}
|
||||
|
||||
// Owns one spawned adapter process + ACP connection. Stateless about sessions —
|
||||
// the manager decides whether to newSession or loadSession.
|
||||
//
|
||||
// The connection is long-lived and reused across follow-up prompts, but each prompt
|
||||
// may stream to a different message's UI, so broker + onEvent are swappable via
|
||||
// setHandlers() rather than fixed at construction.
|
||||
export class AcpClient {
|
||||
readonly agent: CodingAgent;
|
||||
readonly cwd: string;
|
||||
private broker: PermissionBroker;
|
||||
private onEvent: (event: CodeRunEvent) => void;
|
||||
private child?: ChildProcess;
|
||||
private connection?: ClientSideConnection;
|
||||
private loadSession_ = false;
|
||||
// Diagnostics: the adapter's stderr/exit are captured so a dropped connection
|
||||
// reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed".
|
||||
private stderrTail = '';
|
||||
private exitInfo: string | null = null;
|
||||
|
||||
constructor(opts: AcpClientOptions) {
|
||||
this.agent = opts.agent;
|
||||
this.cwd = opts.cwd;
|
||||
this.broker = opts.broker;
|
||||
this.onEvent = opts.onEvent;
|
||||
}
|
||||
|
||||
get loadSupported(): boolean {
|
||||
return this.loadSession_;
|
||||
}
|
||||
|
||||
// Re-point the live connection at a new prompt's broker / event sink.
|
||||
setHandlers(broker: PermissionBroker, onEvent: (event: CodeRunEvent) => void): void {
|
||||
this.broker = broker;
|
||||
this.onEvent = onEvent;
|
||||
}
|
||||
|
||||
// Spawn the adapter and negotiate the protocol. Returns once initialized.
|
||||
async start(): Promise<void> {
|
||||
const spec = getAgentLaunchSpec(this.agent);
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: this.cwd,
|
||||
env: spec.env,
|
||||
// Capture stderr (not inherit) so we can attribute a dropped connection.
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
this.child = child;
|
||||
child.stderr?.on('data', (d: Buffer) => {
|
||||
this.stderrTail = (this.stderrTail + d.toString()).slice(-4000);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`;
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000);
|
||||
});
|
||||
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as ReadableStream<Uint8Array>,
|
||||
);
|
||||
const client = this.buildClient();
|
||||
this.connection = new ClientSideConnection(() => client, stream);
|
||||
|
||||
try {
|
||||
const init = await this.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
this.loadSession_ = init.agentCapabilities?.loadSession === true;
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'initialize');
|
||||
}
|
||||
}
|
||||
|
||||
async newSession(): Promise<string> {
|
||||
try {
|
||||
const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] });
|
||||
return res.sessionId;
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'newSession');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'loadSession');
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
|
||||
try {
|
||||
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'prompt');
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap a connection error with the adapter's exit/stderr so failures are
|
||||
// self-explanatory rather than the SDK's opaque "ACP connection closed".
|
||||
private enrich(err: unknown, phase: string): Error {
|
||||
const base = err instanceof Error ? err.message : String(err);
|
||||
const parts = [
|
||||
this.exitInfo,
|
||||
this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '',
|
||||
].filter(Boolean);
|
||||
return new Error(parts.length ? `${base} — ${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`);
|
||||
}
|
||||
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
await this.conn().cancel({ sessionId });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
try {
|
||||
this.child?.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
this.child = undefined;
|
||||
this.connection = undefined;
|
||||
}
|
||||
|
||||
private conn(): ClientSideConnection {
|
||||
if (!this.connection) throw new Error('AcpClient not started');
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
// The client side of ACP: the agent calls these on us. These read the CURRENT
|
||||
// handlers off `self` so follow-up prompts can swap them via setHandlers().
|
||||
private buildClient(): Client {
|
||||
const self = this;
|
||||
return {
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return self.broker.resolve(params);
|
||||
},
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
self.onEvent(toEvent(params.update));
|
||||
},
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const content = await fs.readFile(params.path, 'utf8');
|
||||
return { content };
|
||||
},
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
await fs.writeFile(params.path, params.content);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
|
||||
import { AcpClient } from './client.js';
|
||||
import { PermissionBroker } from './permission-broker.js';
|
||||
import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js';
|
||||
|
||||
export interface RunPromptArgs {
|
||||
runId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
policy: ApprovalPolicy;
|
||||
/** Called when the policy needs the user to decide (the "ask" path). */
|
||||
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
|
||||
/** Stream sink for this prompt's run. */
|
||||
onEvent: (event: CodeRunEvent) => void;
|
||||
/** Aborts the turn on stop; the manager cancels then force-kills the adapter. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
client: AcpClient;
|
||||
sessionId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
// Prompts currently streaming on this connection. Disposal is deferred while
|
||||
// this is > 0 so we never tear down a connection mid-turn.
|
||||
inflight: number;
|
||||
// Pending grace-window teardown, cleared if the run is reused before it fires.
|
||||
disposeTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// How long a connection stays warm after its last turn ends before we tear it down.
|
||||
// A coding "turn" is one code_agent_run tool call; we keep the adapter briefly so
|
||||
// back-to-back calls within one copilot turn (edit -> test -> fix) and quick user
|
||||
// follow-ups reuse the warm connection instead of cold-starting. Set to 0 for strict
|
||||
// per-turn teardown. Context is never lost either way: the next turn resumes the
|
||||
// persisted session via session/load.
|
||||
const DISPOSE_GRACE_MS = 60_000;
|
||||
|
||||
// On stop, how long to let the adapter cancel gracefully (ACP session/cancel) before
|
||||
// we force-kill it. The kill guarantees the turn unwinds even if the adapter ignores
|
||||
// cancel or is blocked — otherwise a hung prompt would lock the chat indefinitely.
|
||||
const CANCEL_GRACE_MS = 2_000;
|
||||
|
||||
// Drives ACP coding sessions. A connection's lifetime is scoped to the agent turn
|
||||
// (one code_agent_run): it is torn down a short grace window after the turn ends, so
|
||||
// idle chats hold no adapter processes. Turns that land within the grace window reuse
|
||||
// the warm connection; anything colder (grace elapsed, or after an app restart)
|
||||
// resumes the persisted session via session/load.
|
||||
export class CodeModeManager {
|
||||
private readonly runs = new Map<string, ActiveRun>();
|
||||
|
||||
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
|
||||
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args;
|
||||
|
||||
const broker = new PermissionBroker({
|
||||
policy,
|
||||
ask,
|
||||
onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }),
|
||||
});
|
||||
|
||||
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent);
|
||||
run.inflight++;
|
||||
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
const promptP = run.client.prompt(run.sessionId, prompt);
|
||||
// We may stop awaiting this prompt below (force-kill on stop rejects it);
|
||||
// attach a no-op catch so the orphaned rejection isn't flagged.
|
||||
promptP.catch(() => {});
|
||||
|
||||
// Stop handling: on abort, ask the adapter to cancel; if it hasn't unwound
|
||||
// within the grace, force-kill it and resolve as cancelled. This guarantees
|
||||
// the turn ends even if the adapter ignores cancel or is wedged — a hung
|
||||
// prompt would otherwise lock the chat (no run-stopped, composer disabled).
|
||||
const cancelledP = new Promise<{ stopReason: string }>((resolve) => {
|
||||
if (!signal) return;
|
||||
onAbort = () => {
|
||||
run.client.cancel(run.sessionId).catch(() => {});
|
||||
graceTimer = setTimeout(() => {
|
||||
this.dispose(runId);
|
||||
resolve({ stopReason: 'cancelled' });
|
||||
}, CANCEL_GRACE_MS);
|
||||
graceTimer.unref?.();
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
|
||||
const res = await Promise.race([promptP, cancelledP]);
|
||||
return { stopReason: res.stopReason, sessionId: run.sessionId };
|
||||
} catch (e) {
|
||||
// A kill-induced "connection closed" during a stop is an expected cancel.
|
||||
if (signal?.aborted) return { stopReason: 'cancelled', sessionId: run.sessionId };
|
||||
throw e;
|
||||
} finally {
|
||||
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
||||
if (graceTimer) clearTimeout(graceTimer);
|
||||
run.inflight--;
|
||||
this.scheduleDispose(runId);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(runId: string): void {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) return;
|
||||
this.cancelDispose(run);
|
||||
run.client.dispose();
|
||||
this.runs.delete(runId);
|
||||
}
|
||||
|
||||
// Tear down the connection a grace window after its last turn ends. Skipped while a
|
||||
// prompt is still streaming, and re-armed when each turn ends so the window measures
|
||||
// idle-since-last-activity. With grace 0 we dispose immediately (strict per-turn).
|
||||
private scheduleDispose(runId: string): void {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run || run.inflight > 0) return;
|
||||
this.cancelDispose(run);
|
||||
if (DISPOSE_GRACE_MS <= 0) {
|
||||
this.dispose(runId);
|
||||
return;
|
||||
}
|
||||
run.disposeTimer = setTimeout(() => {
|
||||
const r = this.runs.get(runId);
|
||||
if (r && r.inflight === 0) this.dispose(runId);
|
||||
}, DISPOSE_GRACE_MS);
|
||||
// A pending teardown timer must not keep the process alive at quit.
|
||||
run.disposeTimer.unref?.();
|
||||
}
|
||||
|
||||
private cancelDispose(run: ActiveRun): void {
|
||||
if (run.disposeTimer) {
|
||||
clearTimeout(run.disposeTimer);
|
||||
run.disposeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
disposeAll(): void {
|
||||
for (const runId of [...this.runs.keys()]) this.dispose(runId);
|
||||
}
|
||||
|
||||
// Reuse the warm connection if it matches; otherwise (cold start, or the user
|
||||
// switched agent/cwd for this chat) build a fresh one and create-or-resume its session.
|
||||
private async ensureRun(
|
||||
runId: string,
|
||||
agent: CodingAgent,
|
||||
cwd: string,
|
||||
broker: PermissionBroker,
|
||||
onEvent: (event: CodeRunEvent) => void,
|
||||
): Promise<ActiveRun> {
|
||||
const existing = this.runs.get(runId);
|
||||
if (existing && existing.agent === agent && existing.cwd === cwd) {
|
||||
this.cancelDispose(existing); // reused before its grace window elapsed
|
||||
existing.client.setHandlers(broker, onEvent);
|
||||
return existing;
|
||||
}
|
||||
if (existing) this.dispose(runId); // agent/cwd changed — start over
|
||||
|
||||
const client = new AcpClient({ agent, cwd, broker, onEvent });
|
||||
await client.start();
|
||||
|
||||
const sessionId = await this.openSession(runId, agent, cwd, client);
|
||||
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
|
||||
this.runs.set(runId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
// Resume the persisted session for this chat when possible; else start a new one
|
||||
// and persist its id so a later restart can resume it.
|
||||
private async openSession(runId: string, agent: CodingAgent, cwd: string, client: AcpClient): Promise<string> {
|
||||
const stored = await readStoredSession(runId);
|
||||
if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) {
|
||||
try {
|
||||
await client.loadSession(stored.sessionId);
|
||||
return stored.sessionId;
|
||||
} catch {
|
||||
// Stored session is stale/unloadable — fall through to a fresh one.
|
||||
await clearStoredSession(runId);
|
||||
}
|
||||
}
|
||||
const sessionId = await client.newSession();
|
||||
await writeStoredSession({ runId, agent, cwd, sessionId });
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import type {
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { ApprovalPolicy, PermissionDecision, PermissionAsk } from './types.js';
|
||||
|
||||
// Tool kinds that don't mutate anything — eligible for `auto-approve-reads`.
|
||||
const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']);
|
||||
|
||||
function toAsk(request: RequestPermissionRequest): PermissionAsk {
|
||||
const tc = request.toolCall;
|
||||
const kind = tc.kind ?? undefined;
|
||||
const title = tc.title ?? kind ?? 'Tool call';
|
||||
return {
|
||||
toolCallId: tc.toolCallId ?? undefined,
|
||||
title,
|
||||
kind,
|
||||
isRead: kind ? READ_KINDS.has(kind) : false,
|
||||
};
|
||||
}
|
||||
|
||||
// Map a desired decision to one of the options the agent actually offered.
|
||||
// Agents may offer only a subset (e.g. allow_once + reject_once, no allow_always),
|
||||
// so we fall back within the same allow/reject family before giving up.
|
||||
function pickOption(options: PermissionOption[], decision: PermissionDecision): PermissionOption | undefined {
|
||||
const order: Record<PermissionDecision, PermissionOptionKind[]> = {
|
||||
allow_always: ['allow_always', 'allow_once'],
|
||||
allow_once: ['allow_once', 'allow_always'],
|
||||
reject: ['reject_once', 'reject_always'],
|
||||
};
|
||||
for (const kind of order[decision]) {
|
||||
const found = options.find((o) => o.kind === kind);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function selected(optionId: string): RequestPermissionResponse {
|
||||
return { outcome: { outcome: 'selected', optionId } };
|
||||
}
|
||||
|
||||
// A request's identity for "always allow" memory: prefer tool kind, else title.
|
||||
function memoryKey(ask: PermissionAsk): string {
|
||||
return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`;
|
||||
}
|
||||
|
||||
export interface PermissionBrokerOptions {
|
||||
policy: ApprovalPolicy;
|
||||
// Called only when the policy can't decide on its own (the "ask" path).
|
||||
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
|
||||
// Notified of every resolved request so the engine can emit a stream event.
|
||||
onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean) => void;
|
||||
}
|
||||
|
||||
// Decides how to answer the agent's requestPermission calls. Holds per-session
|
||||
// "always allow" memory so a one-time approval sticks for the rest of the run.
|
||||
export class PermissionBroker {
|
||||
private readonly opts: PermissionBrokerOptions;
|
||||
private readonly alwaysAllow = new Set<string>();
|
||||
|
||||
constructor(opts: PermissionBrokerOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async resolve(request: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
const ask = toAsk(request);
|
||||
const key = memoryKey(ask);
|
||||
|
||||
const finish = (decision: PermissionDecision, auto: boolean): RequestPermissionResponse => {
|
||||
if (decision === 'allow_always') this.alwaysAllow.add(key);
|
||||
this.opts.onResolved?.(ask, decision, auto);
|
||||
const opt = pickOption(request.options, decision);
|
||||
// If the agent offered no matching option we fall back to its first one
|
||||
// (don't deadlock the turn); decision precedence above keeps this rare.
|
||||
return selected(opt?.optionId ?? request.options[0]?.optionId ?? '');
|
||||
};
|
||||
|
||||
// 1. Sticky "always allow" from earlier this session.
|
||||
if (this.alwaysAllow.has(key)) return finish('allow_always', true);
|
||||
|
||||
// 2. Policy-level auto decisions.
|
||||
if (this.opts.policy === 'yolo') return finish('allow_always', true);
|
||||
if (this.opts.policy === 'auto-approve-reads' && ask.isRead) return finish('allow_once', true);
|
||||
|
||||
// 3. Ask the user.
|
||||
const decision = await this.opts.ask(ask);
|
||||
return finish(decision, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import type { PermissionDecision } from './types.js';
|
||||
|
||||
interface Pending {
|
||||
runId: string;
|
||||
resolve: (decision: PermissionDecision) => void;
|
||||
}
|
||||
|
||||
// Holds in-flight mid-run permission asks. The agent (via the broker) calls
|
||||
// request() which BLOCKS the coding turn until the user answers; the renderer's
|
||||
// answer arrives over IPC and calls resolve(). This is separate from the LLM
|
||||
// tool-loop's pre-call permission gate, which can't model a mid-execution wait.
|
||||
export class CodePermissionRegistry {
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private counter = 0;
|
||||
|
||||
// Register a pending ask, hand the generated requestId to `emit` (so the caller
|
||||
// can publish the UI event), and resolve once the user answers.
|
||||
request(runId: string, emit: (requestId: string) => void): Promise<PermissionDecision> {
|
||||
const requestId = `cpr-${runId}-${++this.counter}`;
|
||||
return new Promise<PermissionDecision>((resolve) => {
|
||||
this.pending.set(requestId, { runId, resolve });
|
||||
emit(requestId);
|
||||
});
|
||||
}
|
||||
|
||||
// Called from the IPC handler when the user answers a card.
|
||||
resolve(requestId: string, decision: PermissionDecision): void {
|
||||
const entry = this.pending.get(requestId);
|
||||
if (!entry) return;
|
||||
this.pending.delete(requestId);
|
||||
entry.resolve(decision);
|
||||
}
|
||||
|
||||
// On run stop/cancel: reject anything still waiting so the turn can unwind.
|
||||
cancelRun(runId: string): void {
|
||||
for (const [id, entry] of [...this.pending]) {
|
||||
if (entry.runId === runId) {
|
||||
this.pending.delete(id);
|
||||
entry.resolve('reject');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import type { CodingAgent } from './types.js';
|
||||
|
||||
// One ACP session is pinned per chat run. We persist its sessionId (plus the agent
|
||||
// and cwd it belongs to) so reopening the chat after an app restart can resume the
|
||||
// same agent context via session/load instead of starting over.
|
||||
export interface StoredSession {
|
||||
runId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Per-run ACP session state lives in its own directory (not WorkDir/config): it's
|
||||
// runtime state that accumulates one file per chat run, so it's kept separate from
|
||||
// user/app config to be listed and cleaned up on its own.
|
||||
const SESSIONS_DIR = path.join(WorkDir, 'code-mode', 'sessions');
|
||||
|
||||
function sessionFile(runId: string): string {
|
||||
return path.join(SESSIONS_DIR, `${runId}.json`);
|
||||
}
|
||||
|
||||
export async function readStoredSession(runId: string): Promise<StoredSession | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(sessionFile(runId), 'utf8');
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
if (parsed && parsed.sessionId && parsed.agent && parsed.cwd) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStoredSession(session: StoredSession): Promise<void> {
|
||||
const file = sessionFile(session.runId);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, JSON.stringify(session, null, 2));
|
||||
}
|
||||
|
||||
export async function clearStoredSession(runId: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(sessionFile(runId), { force: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// Rowboat-facing types for the ACP code-mode engine. The schemas live in
|
||||
// @x/shared (so the IPC/renderer layers share them); we re-export the inferred
|
||||
// types here so the engine modules import from one local barrel.
|
||||
export type {
|
||||
CodingAgent,
|
||||
ApprovalPolicy,
|
||||
PermissionDecision,
|
||||
PermissionAsk,
|
||||
CodeRunEvent,
|
||||
RunPromptResult,
|
||||
} from '@x/shared/dist/code-mode.js';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
|
||||
export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
|
||||
export { checkCodeModeAgentStatus } from './status.js';
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { CodeModeConfig } from './types.js';
|
||||
import { checkCodeModeAgentStatus } from './status.js';
|
||||
|
||||
export interface ICodeModeConfigRepo {
|
||||
getConfig(): Promise<CodeModeConfig>;
|
||||
setConfig(config: CodeModeConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSCodeModeConfigRepo implements ICodeModeConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json');
|
||||
private agentReadyPromise: Promise<boolean> | null = null;
|
||||
|
||||
// Reuse the existing agent check (Claude Code / Codex installed + signed in),
|
||||
// cached for the process lifetime so we probe (shell + keychain) at most once
|
||||
// per session rather than on every getConfig call.
|
||||
private agentReady(): Promise<boolean> {
|
||||
if (!this.agentReadyPromise) {
|
||||
this.agentReadyPromise = checkCodeModeAgentStatus()
|
||||
.then((s) =>
|
||||
(s.claude.installed && s.claude.signedIn)
|
||||
|| (s.codex.installed && s.codex.signedIn))
|
||||
.catch(() => false);
|
||||
}
|
||||
return this.agentReadyPromise;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<CodeModeConfig> {
|
||||
try {
|
||||
// The file only exists once the user has explicitly toggled code mode
|
||||
// in settings — always honor that choice.
|
||||
const content = await fs.readFile(this.configPath, 'utf8');
|
||||
return CodeModeConfig.parse(JSON.parse(content));
|
||||
} catch {
|
||||
// No explicit choice yet: enable automatically when a coding agent is ready.
|
||||
return { enabled: await this.agentReady() };
|
||||
}
|
||||
}
|
||||
|
||||
async setConfig(config: CodeModeConfig): Promise<void> {
|
||||
const validated = CodeModeConfig.parse(config);
|
||||
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
||||
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Where claude.cmd / codex.cmd typically live when installed via npm/pnpm/yarn.
|
||||
// We scan these directly because Electron's spawned shell sometimes doesn't
|
||||
// inherit the user's full PATH (especially on macOS GUI launches, and even on
|
||||
// Windows when global npm prefix isn't propagated to system PATH).
|
||||
export function commonInstallPaths(binary: string): string[] {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
return [
|
||||
path.join(appData, 'npm', `${binary}.cmd`),
|
||||
path.join(appData, 'npm', `${binary}.exe`),
|
||||
path.join(localAppData, 'npm', `${binary}.cmd`),
|
||||
path.join(localAppData, 'pnpm', `${binary}.cmd`),
|
||||
path.join(home, 'AppData', 'Roaming', 'pnpm', `${binary}.cmd`),
|
||||
path.join(programFiles, 'nodejs', `${binary}.cmd`),
|
||||
path.join(home, '.volta', 'bin', `${binary}.cmd`),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin', // Apple Silicon Homebrew
|
||||
'/usr/bin',
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.volta', 'bin'),
|
||||
path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs
|
||||
path.join(home, 'bin'),
|
||||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
async function probeShell(binary: string): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
|
||||
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
|
||||
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isInstalled(binary: string): Promise<boolean> {
|
||||
if (await probeShell(binary)) return true;
|
||||
// Fallback: scan well-known install locations directly.
|
||||
for (const candidate of commonInstallPaths(binary)) {
|
||||
if (existsSync(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Given the raw credentials JSON (from a file or the macOS Keychain), decide
|
||||
// whether it represents a usable signed-in state: a valid API key, an unexpired
|
||||
// access token, or a refresh token (which can mint a new access token).
|
||||
function isClaudeCredentialSignedIn(raw: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (oauth) {
|
||||
const access = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
|
||||
const refresh = typeof oauth.refreshToken === 'string' ? oauth.refreshToken : '';
|
||||
if (refresh.length > 0) return true;
|
||||
if (access.length > 0) {
|
||||
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt > 0 && oauth.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed.apiKey === 'string' && parsed.apiKey.length > 10) return true;
|
||||
if (typeof parsed.accessToken === 'string' && parsed.accessToken.length > 10) return true;
|
||||
} catch {
|
||||
// malformed JSON
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reads Claude Code's credentials from the macOS login Keychain, where the
|
||||
// CLI stores them on macOS (service "Claude Code-credentials"). On Linux/Windows
|
||||
// it uses the ~/.claude/.credentials.json file instead, so this is a no-op there.
|
||||
//
|
||||
// Caveats:
|
||||
// - The first read by this app (a different binary than the `claude` CLI that
|
||||
// created the item) triggers a one-time macOS authorization dialog; the user
|
||||
// must "Always Allow". Headless/SSH sessions can't show it and will fail.
|
||||
// - If CLAUDE_CONFIG_DIR is set, Claude appends a SHA-256 suffix to the service
|
||||
// name, which this lookup won't match — such setups usually keep the file too.
|
||||
async function readClaudeKeychainCredential(): Promise<string | null> {
|
||||
if (process.platform !== 'darwin') return null;
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`security find-generic-password -s "Claude Code-credentials" -w`,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const out = stdout.trim();
|
||||
return out.length > 0 ? out : null;
|
||||
} catch {
|
||||
// not present in keychain
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validates Claude Code auth. On macOS the credentials live in the login
|
||||
// Keychain; on Linux/Windows in ~/.claude/.credentials.json (or ~/.config
|
||||
// fallback). We check both so detection works across platforms.
|
||||
async function checkClaudeSignedIn(): Promise<boolean> {
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, '.claude', '.credentials.json'),
|
||||
path.join(home, '.config', 'claude', '.credentials.json'),
|
||||
];
|
||||
for (const full of candidates) {
|
||||
try {
|
||||
const raw = await fs.readFile(full, 'utf-8');
|
||||
if (isClaudeCredentialSignedIn(raw)) return true;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: credentials are stored in the Keychain rather than on disk.
|
||||
const keychainRaw = await readClaudeKeychainCredential();
|
||||
if (keychainRaw && isClaudeCredentialSignedIn(keychainRaw)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validates Codex auth at ~/.codex/auth.json on all platforms.
|
||||
// Considered signed in if API key set, or a refresh_token / access_token
|
||||
// exists. id_token expiry is intentionally NOT used as a rejection signal —
|
||||
// id_tokens are short-lived (~1h) but refresh_tokens persist for weeks.
|
||||
async function checkCodexSignedIn(): Promise<boolean> {
|
||||
const home = os.homedir();
|
||||
const full = path.join(home, '.codex', 'auth.json');
|
||||
try {
|
||||
const raw = await fs.readFile(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
|
||||
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
if (tokens) {
|
||||
const refresh = typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '';
|
||||
const access = typeof tokens.access_token === 'string' ? tokens.access_token : '';
|
||||
const id = typeof tokens.id_token === 'string' ? tokens.id_token : '';
|
||||
if (refresh.length > 0 || access.length > 0 || id.length > 0) return true;
|
||||
}
|
||||
} catch {
|
||||
// file missing or unreadable
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exported for diagnostics — silenced unused-var warning by re-export only.
|
||||
export { decodeJwtPayload };
|
||||
|
||||
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
|
||||
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
|
||||
isInstalled('claude'),
|
||||
isInstalled('codex'),
|
||||
checkClaudeSignedIn(),
|
||||
checkCodexSignedIn(),
|
||||
]);
|
||||
return {
|
||||
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
|
||||
codex: { installed: codexInstalled, signedIn: codexSignedIn },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import z from "zod";
|
||||
import { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
|
||||
|
||||
export const CodeModeConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
// How the ACP engine answers the coding agent's permission requests.
|
||||
// Optional for back-compat; the tool defaults to "ask" when unset.
|
||||
approvalPolicy: ApprovalPolicy.optional(),
|
||||
});
|
||||
export type CodeModeConfig = z.infer<typeof CodeModeConfig>;
|
||||
|
||||
export const AgentStatus = z.object({
|
||||
installed: z.boolean(),
|
||||
signedIn: z.boolean(),
|
||||
});
|
||||
export type AgentStatus = z.infer<typeof AgentStatus>;
|
||||
|
||||
export const CodeModeAgentStatus = z.object({
|
||||
claude: AgentStatus,
|
||||
codex: AgentStatus,
|
||||
});
|
||||
export type CodeModeAgentStatus = z.infer<typeof CodeModeAgentStatus>;
|
||||
|
|
@ -42,61 +42,26 @@ const DEFAULT_ALLOW_LIST = [
|
|||
"yq"
|
||||
]
|
||||
|
||||
export type FileAccessOperation = "read" | "list" | "search" | "write" | "delete";
|
||||
|
||||
export type FileAccessGrant = {
|
||||
operation: FileAccessOperation;
|
||||
pathPrefix: string;
|
||||
};
|
||||
|
||||
let cachedAllowList: string[] | null = null;
|
||||
let cachedFileAccessAllowList: FileAccessGrant[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
export async function addToSecurityConfig(commands: string[]): Promise<void> {
|
||||
ensureSecurityConfigSync();
|
||||
const current = readSecurityConfig();
|
||||
const merged = new Set(current.allowedCommands);
|
||||
const current = readAllowList();
|
||||
const merged = new Set(current);
|
||||
for (const cmd of commands) {
|
||||
const normalized = cmd.trim().toLowerCase();
|
||||
if (normalized) merged.add(normalized);
|
||||
}
|
||||
await fsPromises.writeFile(
|
||||
SECURITY_CONFIG_PATH,
|
||||
JSON.stringify({
|
||||
allowedCommands: Array.from(merged).sort(),
|
||||
allowedFileAccess: current.allowedFileAccess,
|
||||
}, null, 2) + "\n",
|
||||
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
// Reset cache so next read picks up the new file
|
||||
resetSecurityAllowListCache();
|
||||
}
|
||||
|
||||
export async function addFileAccessGrant(grant: FileAccessGrant): Promise<void> {
|
||||
ensureSecurityConfigSync();
|
||||
const current = readSecurityConfig();
|
||||
const normalizedGrant = normalizeFileAccessGrant(grant);
|
||||
const exists = current.allowedFileAccess.some(existing =>
|
||||
existing.operation === normalizedGrant.operation
|
||||
&& existing.pathPrefix === normalizedGrant.pathPrefix
|
||||
);
|
||||
const allowedFileAccess = exists
|
||||
? current.allowedFileAccess
|
||||
: [...current.allowedFileAccess, normalizedGrant].sort((a, b) =>
|
||||
`${a.operation}:${a.pathPrefix}`.localeCompare(`${b.operation}:${b.pathPrefix}`)
|
||||
);
|
||||
await fsPromises.writeFile(
|
||||
SECURITY_CONFIG_PATH,
|
||||
JSON.stringify({
|
||||
allowedCommands: current.allowedCommands,
|
||||
allowedFileAccess,
|
||||
}, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
resetSecurityAllowListCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async function to ensure security config file exists.
|
||||
* Called explicitly at app startup via initConfigs().
|
||||
|
|
@ -138,74 +103,28 @@ function normalizeList(commands: unknown[]): string[] {
|
|||
return Array.from(seen);
|
||||
}
|
||||
|
||||
function normalizeFileAccessGrant(grant: FileAccessGrant): FileAccessGrant {
|
||||
return {
|
||||
operation: grant.operation,
|
||||
pathPrefix: path.resolve(grant.pathPrefix),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileAccessList(grants: unknown[]): FileAccessGrant[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: FileAccessGrant[] = [];
|
||||
for (const entry of grants) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const maybeGrant = entry as Record<string, unknown>;
|
||||
const operation = maybeGrant.operation;
|
||||
const pathPrefix = maybeGrant.pathPrefix;
|
||||
if (
|
||||
operation !== "read"
|
||||
&& operation !== "list"
|
||||
&& operation !== "search"
|
||||
&& operation !== "write"
|
||||
&& operation !== "delete"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (typeof pathPrefix !== "string" || !pathPrefix.trim()) continue;
|
||||
const grant = normalizeFileAccessGrant({ operation, pathPrefix });
|
||||
const key = `${grant.operation}:${grant.pathPrefix}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(grant);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseSecurityPayload(payload: unknown): { allowedCommands: string[]; allowedFileAccess: FileAccessGrant[] } {
|
||||
function parseSecurityPayload(payload: unknown): string[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return { allowedCommands: normalizeList(payload), allowedFileAccess: [] };
|
||||
return normalizeList(payload);
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object") {
|
||||
const maybeObject = payload as Record<string, unknown>;
|
||||
const allowedFileAccess = Array.isArray(maybeObject.allowedFileAccess)
|
||||
? normalizeFileAccessList(maybeObject.allowedFileAccess)
|
||||
: [];
|
||||
|
||||
if (Array.isArray(maybeObject.allowedCommands) || Array.isArray(maybeObject.allowedFileAccess)) {
|
||||
return {
|
||||
allowedCommands: Array.isArray(maybeObject.allowedCommands)
|
||||
? normalizeList(maybeObject.allowedCommands)
|
||||
: [],
|
||||
allowedFileAccess,
|
||||
};
|
||||
if (Array.isArray(maybeObject.allowedCommands)) {
|
||||
return normalizeList(maybeObject.allowedCommands);
|
||||
}
|
||||
|
||||
const dynamicList = Object.entries(maybeObject)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
return {
|
||||
allowedCommands: normalizeList(dynamicList),
|
||||
allowedFileAccess,
|
||||
};
|
||||
return normalizeList(dynamicList);
|
||||
}
|
||||
|
||||
return { allowedCommands: [], allowedFileAccess: [] };
|
||||
return [];
|
||||
}
|
||||
|
||||
function readSecurityConfig(): { allowedCommands: string[]; allowedFileAccess: FileAccessGrant[] } {
|
||||
function readAllowList(): string[] {
|
||||
ensureSecurityConfigSync();
|
||||
|
||||
try {
|
||||
|
|
@ -214,14 +133,10 @@ function readSecurityConfig(): { allowedCommands: string[]; allowedFileAccess: F
|
|||
return parseSecurityPayload(parsed);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`);
|
||||
return { allowedCommands: DEFAULT_ALLOW_LIST, allowedFileAccess: [] };
|
||||
return DEFAULT_ALLOW_LIST;
|
||||
}
|
||||
}
|
||||
|
||||
function readAllowList(): string[] {
|
||||
return readSecurityConfig().allowedCommands;
|
||||
}
|
||||
|
||||
export function getSecurityAllowList(): string[] {
|
||||
ensureSecurityConfigSync();
|
||||
try {
|
||||
|
|
@ -234,32 +149,12 @@ export function getSecurityAllowList(): string[] {
|
|||
return cachedAllowList;
|
||||
} catch {
|
||||
cachedAllowList = null;
|
||||
cachedFileAccessAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
return readAllowList();
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileAccessAllowList(): FileAccessGrant[] {
|
||||
ensureSecurityConfigSync();
|
||||
try {
|
||||
const stats = fs.statSync(SECURITY_CONFIG_PATH);
|
||||
if (cachedFileAccessAllowList && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedFileAccessAllowList;
|
||||
}
|
||||
cachedFileAccessAllowList = readSecurityConfig().allowedFileAccess;
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return cachedFileAccessAllowList;
|
||||
} catch {
|
||||
cachedAllowList = null;
|
||||
cachedFileAccessAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
return readSecurityConfig().allowedFileAccess;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSecurityAllowListCache() {
|
||||
cachedAllowList = null;
|
||||
cachedFileAccessAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,10 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
|||
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
|
||||
import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js";
|
||||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||
import { CodeModeManager } from "../code-mode/acp/manager.js";
|
||||
import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js";
|
||||
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||
import type { INotificationService } from "../application/notification/service.js";
|
||||
|
||||
|
|
@ -41,16 +38,9 @@ container.register({
|
|||
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
||||
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
||||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||
codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(),
|
||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
|
||||
// ACP code-mode engine: the manager holds a live agent connection per chat only
|
||||
// around an active turn (torn down after a short idle grace; resumed via
|
||||
// session/load); the registry brokers mid-run approvals.
|
||||
codeModeManager: asClass(CodeModeManager).singleton(),
|
||||
codePermissionRegistry: asClass(CodePermissionRegistry).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let tmpDir: string;
|
||||
let workspaceDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-files-test-"));
|
||||
workspaceDir = path.join(tmpDir, "workspace");
|
||||
process.env.ROWBOAT_WORKDIR = workspaceDir;
|
||||
vi.resetModules();
|
||||
vi.doMock("../knowledge/version_history.js", () => ({
|
||||
commitAll: vi.fn(async () => undefined),
|
||||
initRepo: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../knowledge/deprecate_today_note.js", () => ({
|
||||
deprecateTodayNote: vi.fn(async () => undefined),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ROWBOAT_WORKDIR;
|
||||
vi.doUnmock("../knowledge/version_history.js");
|
||||
vi.doUnmock("../knowledge/deprecate_today_note.js");
|
||||
vi.resetModules();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function loadFiles() {
|
||||
return import("./files.js");
|
||||
}
|
||||
|
||||
describe("filesystem files", () => {
|
||||
it("resolves relative paths inside ROWBOAT_WORKDIR", async () => {
|
||||
const files = await loadFiles();
|
||||
|
||||
const resolved = files.resolveFilePath("notes/example.md");
|
||||
|
||||
expect(resolved.originalPath).toBe("notes/example.md");
|
||||
expect(resolved.resolvedPath).toBe(path.join(workspaceDir, "notes", "example.md"));
|
||||
expect(resolved.isInsideWorkspace).toBe(true);
|
||||
expect(resolved.workspaceRelPath).toBe("notes/example.md");
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside the workspace absolute", async () => {
|
||||
const files = await loadFiles();
|
||||
const absolutePath = path.join(tmpDir, "outside.txt");
|
||||
|
||||
const resolved = files.resolveFilePath(absolutePath);
|
||||
|
||||
expect(resolved.resolvedPath).toBe(absolutePath);
|
||||
expect(resolved.isInsideWorkspace).toBe(false);
|
||||
expect(resolved.workspaceRelPath).toBeNull();
|
||||
});
|
||||
|
||||
it("expands home-relative paths", async () => {
|
||||
const files = await loadFiles();
|
||||
|
||||
const resolved = files.resolveFilePath("~/rowboat-test.txt");
|
||||
|
||||
expect(resolved.resolvedPath).toBe(path.join(os.homedir(), "rowboat-test.txt"));
|
||||
expect(resolved.isInsideWorkspace).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes symlinked paths for permission checks", async () => {
|
||||
const files = await loadFiles();
|
||||
const externalDir = path.join(tmpDir, "external");
|
||||
const linkPath = path.join(workspaceDir, "linked");
|
||||
await fs.mkdir(externalDir, { recursive: true });
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
try {
|
||||
await fs.symlink(externalDir, linkPath, "dir");
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const canonicalExternalDir = await fs.realpath(externalDir);
|
||||
const resolved = await files.resolveFilePathForPermission("linked/new-file.txt");
|
||||
|
||||
expect(resolved.resolvedPath).toBe(path.join(workspaceDir, "linked", "new-file.txt"));
|
||||
expect(resolved.canonicalPath).toBe(path.join(canonicalExternalDir, "new-file.txt"));
|
||||
expect(resolved.isInsideWorkspace).toBe(false);
|
||||
expect(resolved.workspaceRelPath).toBeNull();
|
||||
});
|
||||
|
||||
it("reads text with line numbers and pagination metadata", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "readme.txt"), "alpha\nbeta\ngamma\n", "utf8");
|
||||
|
||||
const result = await files.readText("readme.txt", 2, 1);
|
||||
|
||||
expect(result.path).toBe("readme.txt");
|
||||
expect(result.encoding).toBe("utf8");
|
||||
expect(result.offset).toBe(2);
|
||||
expect(result.limit).toBe(1);
|
||||
expect(result.totalLines).toBe(3);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.content).toContain("2: beta");
|
||||
});
|
||||
|
||||
it("rejects files containing NUL bytes as binary", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "binary.dat"), Buffer.from([0x61, 0x00, 0x62]));
|
||||
|
||||
await expect(files.readText("binary.dat")).rejects.toThrow("binary file");
|
||||
});
|
||||
|
||||
it("rejects files with a high non-printable byte ratio", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "control.dat"), Buffer.from([0x01, 0x02, 0x03, 0x41]));
|
||||
|
||||
await expect(files.readText("control.dat")).rejects.toThrow("binary file");
|
||||
});
|
||||
|
||||
it("rejects files that decode with many UTF-8 replacement characters", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "invalid-utf8.txt"), Buffer.alloc(512, 0xff));
|
||||
|
||||
await expect(files.readText("invalid-utf8.txt")).rejects.toThrow("binary file");
|
||||
});
|
||||
|
||||
it("accepts normal text control characters", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "tabs.txt"), "one\ttwo\nthree\rfour\r\n", "utf8");
|
||||
|
||||
const result = await files.readText("tabs.txt");
|
||||
|
||||
expect(result.content).toContain("1: one\ttwo");
|
||||
expect(result.content).toContain("2: three");
|
||||
});
|
||||
|
||||
it("writes text and creates parent directories", async () => {
|
||||
const files = await loadFiles();
|
||||
|
||||
await files.writeText("nested/dir/file.txt", "hello");
|
||||
|
||||
await expect(fs.readFile(path.join(workspaceDir, "nested", "dir", "file.txt"), "utf8")).resolves.toBe("hello");
|
||||
});
|
||||
|
||||
it("rejects stale expectedEtag writes", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "etag.txt"), "first", "utf8");
|
||||
const initial = await files.stat("etag.txt");
|
||||
await files.writeText("etag.txt", "second");
|
||||
|
||||
await expect(files.writeText("etag.txt", "third", { expectedEtag: initial.etag })).rejects.toThrow("ETag mismatch");
|
||||
});
|
||||
|
||||
it("requires unique editText matches unless replaceAll is true", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "edit.txt"), "one two one", "utf8");
|
||||
|
||||
const ambiguous = await files.editText("edit.txt", "one", "ONE");
|
||||
expect(ambiguous).toEqual({ error: "oldString found 2 times. Use replaceAll: true or provide more context to make it unique." });
|
||||
|
||||
const replaced = await files.editText("edit.txt", "one", "ONE", true);
|
||||
expect(replaced).toMatchObject({ success: true, replacements: 2 });
|
||||
await expect(fs.readFile(path.join(workspaceDir, "edit.txt"), "utf8")).resolves.toBe("ONE two ONE");
|
||||
});
|
||||
|
||||
it("runs glob relative to the requested cwd", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(path.join(workspaceDir, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "src", "a.ts"), "export {};", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "src", "b.md"), "# b", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "c.ts"), "export {};", "utf8");
|
||||
|
||||
const result = await files.glob("*.ts", "src");
|
||||
|
||||
expect(result.files).toEqual(["a.ts"]);
|
||||
expect(result.resolvedFiles).toEqual([path.join(workspaceDir, "src", "a.ts")]);
|
||||
expect(result.resolvedCwd).toBe(path.join(workspaceDir, "src"));
|
||||
});
|
||||
|
||||
it("greps text files and skips binary files", async () => {
|
||||
const files = await loadFiles();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "match.txt"), "needle\n", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "binary.dat"), Buffer.from([0x6e, 0x65, 0x65, 0x64, 0x6c, 0x65, 0x00]));
|
||||
|
||||
const result = await files.grep({ pattern: "needle", searchPath: "." });
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.matches).toEqual([
|
||||
expect.objectContaining({
|
||||
file: "match.txt",
|
||||
line: 1,
|
||||
content: "needle",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import { createReadStream, type Stats } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { glob as globFiles } from 'glob';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { withFileLock } from '../knowledge/file-lock.js';
|
||||
import { commitAll } from '../knowledge/version_history.js';
|
||||
import { rewriteWikiLinksForRenamedKnowledgeFile } from '../workspace/wiki-link-rewrite.js';
|
||||
|
||||
export type FileOperation = 'read' | 'list' | 'search' | 'write' | 'delete';
|
||||
|
||||
export type ResolvedFilePath = {
|
||||
originalPath: string;
|
||||
resolvedPath: string;
|
||||
isInsideWorkspace: boolean;
|
||||
workspaceRelPath: string | null;
|
||||
};
|
||||
|
||||
export type CanonicalFilePath = ResolvedFilePath & {
|
||||
canonicalPath: string;
|
||||
};
|
||||
|
||||
export type FileStat = {
|
||||
kind: 'file' | 'dir';
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
isSymlink?: boolean;
|
||||
};
|
||||
|
||||
export type DirEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
kind: 'file' | 'dir';
|
||||
stat?: {
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReaddirOptions = {
|
||||
recursive?: boolean;
|
||||
includeStats?: boolean;
|
||||
includeHidden?: boolean;
|
||||
allowedExtensions?: string[];
|
||||
};
|
||||
|
||||
export type WriteTextOptions = {
|
||||
atomic?: boolean;
|
||||
mkdirp?: boolean;
|
||||
expectedEtag?: string;
|
||||
};
|
||||
|
||||
export type RemoveOptions = {
|
||||
recursive?: boolean;
|
||||
trash?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000;
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
const MAX_BYTES = 50 * 1024;
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
|
||||
|
||||
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let canonicalWorkspaceRoot: string | null = null;
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function expandHomePath(inputPath: string): string {
|
||||
const trimmed = inputPath.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Path is required');
|
||||
}
|
||||
if (trimmed === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveFilePath(inputPath: string): ResolvedFilePath {
|
||||
const originalPath = inputPath;
|
||||
const expandedPath = expandHomePath(inputPath);
|
||||
const resolvedPath = path.resolve(path.isAbsolute(expandedPath) ? expandedPath : path.join(WorkDir, expandedPath));
|
||||
const workspaceRoot = path.resolve(WorkDir);
|
||||
const isInsideWorkspace = isPathInside(workspaceRoot, resolvedPath);
|
||||
const workspaceRelPath = isInsideWorkspace
|
||||
? path.relative(workspaceRoot, resolvedPath).split(path.sep).join('/')
|
||||
: null;
|
||||
return { originalPath, resolvedPath, isInsideWorkspace, workspaceRelPath };
|
||||
}
|
||||
|
||||
async function getCanonicalWorkspaceRoot(): Promise<string> {
|
||||
if (canonicalWorkspaceRoot) return canonicalWorkspaceRoot;
|
||||
try {
|
||||
canonicalWorkspaceRoot = await fs.realpath(WorkDir);
|
||||
} catch {
|
||||
canonicalWorkspaceRoot = path.resolve(WorkDir);
|
||||
}
|
||||
return canonicalWorkspaceRoot;
|
||||
}
|
||||
|
||||
async function canonicalizePathForPermission(resolvedPath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(resolvedPath);
|
||||
} catch {
|
||||
const parsed = path.parse(resolvedPath);
|
||||
const missingParts: string[] = [];
|
||||
let current = resolvedPath;
|
||||
|
||||
while (current !== parsed.root) {
|
||||
try {
|
||||
const canonicalParent = await fs.realpath(current);
|
||||
return path.join(canonicalParent, ...missingParts.reverse());
|
||||
} catch {
|
||||
missingParts.push(path.basename(current));
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
return path.resolve(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveFilePathForPermission(inputPath: string): Promise<CanonicalFilePath> {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
const [canonicalPath, workspaceRoot] = await Promise.all([
|
||||
canonicalizePathForPermission(resolved.resolvedPath),
|
||||
getCanonicalWorkspaceRoot(),
|
||||
]);
|
||||
const isInsideWorkspace = isPathInside(workspaceRoot, canonicalPath);
|
||||
const workspaceRelPath = isInsideWorkspace
|
||||
? path.relative(workspaceRoot, canonicalPath).split(path.sep).join('/')
|
||||
: null;
|
||||
return {
|
||||
...resolved,
|
||||
canonicalPath,
|
||||
isInsideWorkspace,
|
||||
workspaceRelPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeEtag(size: number, mtimeMs: number): string {
|
||||
return `${size}:${mtimeMs}`;
|
||||
}
|
||||
|
||||
function statToSchema(stats: Stats): FileStat {
|
||||
return {
|
||||
kind: stats.isDirectory() ? 'dir' : 'file',
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
ctimeMs: stats.ctimeMs,
|
||||
isSymlink: stats.isSymbolicLink() ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleKnowledgeCommit(filename: string): void {
|
||||
if (knowledgeCommitTimer) {
|
||||
clearTimeout(knowledgeCommitTimer);
|
||||
}
|
||||
knowledgeCommitTimer = setTimeout(() => {
|
||||
knowledgeCommitTimer = null;
|
||||
commitAll(`Edit ${filename}`, 'You').catch(err => {
|
||||
console.error('[VersionHistory] Failed to commit after edit:', err);
|
||||
});
|
||||
}, 3 * 60 * 1000);
|
||||
}
|
||||
|
||||
function isKnowledgeMarkdownPath(resolved: ResolvedFilePath): boolean {
|
||||
return !!resolved.workspaceRelPath
|
||||
&& resolved.workspaceRelPath.startsWith('knowledge/')
|
||||
&& resolved.workspaceRelPath.endsWith('.md');
|
||||
}
|
||||
|
||||
function scheduleKnowledgeCommitIfNeeded(resolved: ResolvedFilePath): void {
|
||||
if (isKnowledgeMarkdownPath(resolved)) {
|
||||
scheduleKnowledgeCommit(path.basename(resolved.resolvedPath));
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTextFile(resolvedPath: string): Promise<void> {
|
||||
const stats = await fs.lstat(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Path is not a file');
|
||||
}
|
||||
|
||||
if (stats.size === 0) return;
|
||||
const handle = await fs.open(resolvedPath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(stats.size, 8192));
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
const sample = buffer.subarray(0, bytesRead);
|
||||
let nonPrintableCount = 0;
|
||||
for (let index = 0; index < sample.length; index++) {
|
||||
const byte = sample[index];
|
||||
if (byte === 0) {
|
||||
throw new Error('Refusing to read binary file as text');
|
||||
}
|
||||
if (byte < 9 || (byte > 13 && byte < 32)) {
|
||||
nonPrintableCount++;
|
||||
}
|
||||
}
|
||||
if (sample.length > 0 && nonPrintableCount / sample.length > 0.3) {
|
||||
throw new Error('Refusing to read binary file as text');
|
||||
}
|
||||
const decoded = sample.toString('utf8');
|
||||
const replacementChars = (decoded.match(/\uFFFD/g) || []).length;
|
||||
if (replacementChars > Math.max(3, decoded.length * 0.01)) {
|
||||
throw new Error('Refusing to read binary file as text');
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function exists(inputPath: string): Promise<{ exists: boolean; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
try {
|
||||
await fs.access(resolved.resolvedPath);
|
||||
return { exists: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
|
||||
} catch {
|
||||
return { exists: false, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
|
||||
}
|
||||
}
|
||||
|
||||
export async function stat(inputPath: string): Promise<FileStat & { path: string; resolvedPath: string; isInsideWorkspace: boolean; etag: string }> {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
const stats = await fs.lstat(resolved.resolvedPath);
|
||||
return {
|
||||
...statToSchema(stats),
|
||||
path: resolved.originalPath,
|
||||
resolvedPath: resolved.resolvedPath,
|
||||
isInsideWorkspace: resolved.isInsideWorkspace,
|
||||
etag: computeEtag(stats.size, stats.mtimeMs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function list(inputPath: string, opts?: ReaddirOptions): Promise<Array<DirEntry>> {
|
||||
const root = resolveFilePath(inputPath || '.');
|
||||
const entries: Array<DirEntry> = [];
|
||||
|
||||
async function readDir(currentPath: string, currentDisplayPath: string): Promise<void> {
|
||||
const items = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
if (!opts?.includeHidden && item.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemPath = path.join(currentPath, item.name);
|
||||
const displayPath = path.posix.join(currentDisplayPath.split(path.sep).join('/'), item.name);
|
||||
const itemKind = item.isDirectory() ? 'dir' : item.isFile() ? 'file' : null;
|
||||
if (!itemKind) continue;
|
||||
|
||||
if (itemKind === 'file' && opts?.allowedExtensions?.length) {
|
||||
const ext = path.extname(item.name);
|
||||
if (!opts.allowedExtensions.includes(ext)) continue;
|
||||
}
|
||||
|
||||
let itemStat: { size: number; mtimeMs: number } | undefined;
|
||||
if (opts?.includeStats) {
|
||||
const stats = await fs.lstat(itemPath);
|
||||
itemStat = { size: stats.size, mtimeMs: stats.mtimeMs };
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: item.name,
|
||||
path: displayPath,
|
||||
resolvedPath: itemPath,
|
||||
kind: itemKind,
|
||||
stat: itemStat,
|
||||
});
|
||||
|
||||
if (itemKind === 'dir' && opts?.recursive) {
|
||||
await readDir(itemPath, displayPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await readDir(root.resolvedPath, root.originalPath || '.');
|
||||
entries.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function readText(inputPath: string, offset?: number, limit?: number) {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
await assertTextFile(resolved.resolvedPath);
|
||||
const stats = await fs.lstat(resolved.resolvedPath);
|
||||
const stat = statToSchema(stats);
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
const effectiveOffset = offset ?? 1;
|
||||
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
|
||||
const start = effectiveOffset - 1;
|
||||
|
||||
const stream = createReadStream(resolved.resolvedPath, { encoding: 'utf8' });
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const collected: string[] = [];
|
||||
let totalLines = 0;
|
||||
let bytes = 0;
|
||||
let truncatedByBytes = false;
|
||||
let hasMoreLines = false;
|
||||
|
||||
try {
|
||||
for await (const text of rl) {
|
||||
totalLines += 1;
|
||||
if (totalLines <= start) continue;
|
||||
|
||||
if (collected.length >= effectiveLimit) {
|
||||
hasMoreLines = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH
|
||||
? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
|
||||
: text;
|
||||
const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0);
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
truncatedByBytes = true;
|
||||
hasMoreLines = true;
|
||||
break;
|
||||
}
|
||||
|
||||
collected.push(line);
|
||||
bytes += size;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) {
|
||||
return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` };
|
||||
}
|
||||
|
||||
const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`);
|
||||
const lastReadLine = effectiveOffset + collected.length - 1;
|
||||
const nextOffset = lastReadLine + 1;
|
||||
let footer: string;
|
||||
if (truncatedByBytes) {
|
||||
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`;
|
||||
} else if (hasMoreLines) {
|
||||
footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`;
|
||||
} else {
|
||||
footer = `(End of file - total ${totalLines} lines)`;
|
||||
}
|
||||
|
||||
const content = [
|
||||
`<path>${resolved.originalPath}</path>`,
|
||||
`<resolvedPath>${resolved.resolvedPath}</resolvedPath>`,
|
||||
`<type>file</type>`,
|
||||
`<content>`,
|
||||
prefixed.join('\n'),
|
||||
'',
|
||||
footer,
|
||||
`</content>`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
path: resolved.originalPath,
|
||||
resolvedPath: resolved.resolvedPath,
|
||||
isInsideWorkspace: resolved.isInsideWorkspace,
|
||||
encoding: 'utf8' as const,
|
||||
content,
|
||||
stat,
|
||||
etag,
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
totalLines,
|
||||
hasMore: hasMoreLines || truncatedByBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readBuffer(inputPath: string): Promise<{ buffer: Buffer; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
const buffer = await fs.readFile(resolved.resolvedPath);
|
||||
return {
|
||||
buffer,
|
||||
path: resolved.originalPath,
|
||||
resolvedPath: resolved.resolvedPath,
|
||||
isInsideWorkspace: resolved.isInsideWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeText(inputPath: string, data: string, opts?: WriteTextOptions) {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
const atomic = opts?.atomic !== false;
|
||||
const mkdirp = opts?.mkdirp !== false;
|
||||
|
||||
if (mkdirp) {
|
||||
await fs.mkdir(path.dirname(resolved.resolvedPath), { recursive: true });
|
||||
}
|
||||
|
||||
const result = await withFileLock(resolved.resolvedPath, async () => {
|
||||
if (opts?.expectedEtag) {
|
||||
const existingStats = await fs.lstat(resolved.resolvedPath);
|
||||
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
|
||||
if (existingEtag !== opts.expectedEtag) {
|
||||
throw new Error('File was modified (ETag mismatch)');
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, 'utf8');
|
||||
if (atomic) {
|
||||
const tempPath = `${resolved.resolvedPath}.tmp.${Date.now()}${Math.random().toString(36).slice(2)}`;
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
await fs.rename(tempPath, resolved.resolvedPath);
|
||||
} else {
|
||||
await fs.writeFile(resolved.resolvedPath, buffer);
|
||||
}
|
||||
|
||||
const stats = await fs.lstat(resolved.resolvedPath);
|
||||
return { stat: statToSchema(stats), etag: computeEtag(stats.size, stats.mtimeMs) };
|
||||
});
|
||||
|
||||
scheduleKnowledgeCommitIfNeeded(resolved);
|
||||
return {
|
||||
path: resolved.originalPath,
|
||||
resolvedPath: resolved.resolvedPath,
|
||||
isInsideWorkspace: resolved.isInsideWorkspace,
|
||||
stat: result.stat,
|
||||
etag: result.etag,
|
||||
};
|
||||
}
|
||||
|
||||
export async function editText(inputPath: string, oldString: string, newString: string, replaceAll = false) {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
await assertTextFile(resolved.resolvedPath);
|
||||
const content = await fs.readFile(resolved.resolvedPath, 'utf8');
|
||||
const occurrences = content.split(oldString).length - 1;
|
||||
|
||||
if (occurrences === 0) {
|
||||
return { error: 'oldString not found in file' };
|
||||
}
|
||||
if (occurrences > 1 && !replaceAll) {
|
||||
return { error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.` };
|
||||
}
|
||||
|
||||
const newContent = replaceAll
|
||||
? content.replaceAll(oldString, newString)
|
||||
: content.replace(oldString, newString);
|
||||
await writeText(inputPath, newContent, { atomic: true, mkdirp: true });
|
||||
return {
|
||||
success: true,
|
||||
path: resolved.originalPath,
|
||||
resolvedPath: resolved.resolvedPath,
|
||||
isInsideWorkspace: resolved.isInsideWorkspace,
|
||||
replacements: replaceAll ? occurrences : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mkdir(inputPath: string, recursive = true): Promise<{ ok: true; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
await fs.mkdir(resolved.resolvedPath, { recursive });
|
||||
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
|
||||
}
|
||||
|
||||
export async function rename(from: string, to: string, overwrite = false): Promise<{ ok: true; from: string; to: string; resolvedFrom: string; resolvedTo: string }> {
|
||||
const source = resolveFilePath(from);
|
||||
const dest = resolveFilePath(to);
|
||||
await fs.access(source.resolvedPath);
|
||||
const fromStats = await fs.lstat(source.resolvedPath);
|
||||
|
||||
if (!overwrite) {
|
||||
try {
|
||||
await fs.access(dest.resolvedPath);
|
||||
throw new Error('Destination already exists');
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
|
||||
// destination does not exist
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(dest.resolvedPath), { recursive: true });
|
||||
await fs.rename(source.resolvedPath, dest.resolvedPath);
|
||||
|
||||
if (fromStats.isFile() && isKnowledgeMarkdownPath(source) && isKnowledgeMarkdownPath(dest) && source.workspaceRelPath && dest.workspaceRelPath) {
|
||||
try {
|
||||
await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, source.workspaceRelPath, dest.workspaceRelPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to rewrite wiki backlinks after file rename:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, from, to, resolvedFrom: source.resolvedPath, resolvedTo: dest.resolvedPath };
|
||||
}
|
||||
|
||||
export async function copy(from: string, to: string, overwrite = false): Promise<{ ok: true; from: string; to: string; resolvedFrom: string; resolvedTo: string }> {
|
||||
const source = resolveFilePath(from);
|
||||
const dest = resolveFilePath(to);
|
||||
const fromStats = await fs.lstat(source.resolvedPath);
|
||||
if (fromStats.isDirectory()) {
|
||||
throw new Error('Copying directories is not supported');
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
try {
|
||||
await fs.access(dest.resolvedPath);
|
||||
throw new Error('Destination already exists');
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
|
||||
// destination does not exist
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(dest.resolvedPath), { recursive: true });
|
||||
await fs.copyFile(source.resolvedPath, dest.resolvedPath);
|
||||
return { ok: true, from, to, resolvedFrom: source.resolvedPath, resolvedTo: dest.resolvedPath };
|
||||
}
|
||||
|
||||
export async function remove(inputPath: string, opts?: RemoveOptions): Promise<{ ok: true; path: string; resolvedPath: string; trashed?: string }> {
|
||||
const resolved = resolveFilePath(inputPath);
|
||||
const stats = await fs.lstat(resolved.resolvedPath);
|
||||
const trash = opts?.trash !== false;
|
||||
|
||||
if (trash) {
|
||||
const trashDir = path.join(WorkDir, '.trash');
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
const timestamp = Date.now();
|
||||
const basename = path.basename(resolved.resolvedPath);
|
||||
let finalTrashPath = path.join(trashDir, `${timestamp}-${basename}`);
|
||||
let counter = 1;
|
||||
while (true) {
|
||||
try {
|
||||
await fs.access(finalTrashPath);
|
||||
finalTrashPath = path.join(trashDir, `${timestamp}-${counter}-${basename}`);
|
||||
counter++;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await fs.rename(resolved.resolvedPath, finalTrashPath);
|
||||
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, trashed: finalTrashPath };
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (!opts?.recursive) {
|
||||
throw new Error('Cannot remove directory without recursive=true');
|
||||
}
|
||||
await fs.rm(resolved.resolvedPath, { recursive: true });
|
||||
} else {
|
||||
await fs.unlink(resolved.resolvedPath);
|
||||
}
|
||||
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath };
|
||||
}
|
||||
|
||||
export async function glob(pattern: string, cwd?: string): Promise<{ files: string[]; resolvedFiles: string[]; count: number; pattern: string; cwd: string; resolvedCwd: string }> {
|
||||
const root = resolveFilePath(cwd || '.');
|
||||
const files = await globFiles(pattern, {
|
||||
cwd: root.resolvedPath,
|
||||
nodir: true,
|
||||
ignore: ['node_modules/**', '.git/**'],
|
||||
});
|
||||
const resolvedFiles = files.map(file => path.resolve(root.resolvedPath, file));
|
||||
return {
|
||||
files,
|
||||
resolvedFiles,
|
||||
count: files.length,
|
||||
pattern,
|
||||
cwd: cwd || '.',
|
||||
resolvedCwd: root.resolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function grep({
|
||||
pattern,
|
||||
searchPath,
|
||||
fileGlob,
|
||||
contextLines = 0,
|
||||
maxResults = 100,
|
||||
}: {
|
||||
pattern: string;
|
||||
searchPath?: string;
|
||||
fileGlob?: string;
|
||||
contextLines?: number;
|
||||
maxResults?: number;
|
||||
}) {
|
||||
const root = resolveFilePath(searchPath || '.');
|
||||
const stats = await fs.lstat(root.resolvedPath);
|
||||
const candidates = stats.isDirectory()
|
||||
? await globFiles(fileGlob || '**/*', {
|
||||
cwd: root.resolvedPath,
|
||||
nodir: true,
|
||||
ignore: ['node_modules/**', '.git/**'],
|
||||
dot: false,
|
||||
})
|
||||
: [path.basename(root.resolvedPath)];
|
||||
const baseDir = stats.isDirectory() ? root.resolvedPath : path.dirname(root.resolvedPath);
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
const matches: Array<{ file: string; resolvedPath: string; line: number; content: string; before?: string[]; after?: string[] }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (matches.length >= maxResults) break;
|
||||
const resolvedPath = stats.isDirectory() ? path.resolve(baseDir, candidate) : root.resolvedPath;
|
||||
try {
|
||||
await assertTextFile(resolvedPath);
|
||||
const text = await fs.readFile(resolvedPath, 'utf8');
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (!regex.test(lines[index])) continue;
|
||||
const before = contextLines > 0 ? lines.slice(Math.max(0, index - contextLines), index) : undefined;
|
||||
const after = contextLines > 0 ? lines.slice(index + 1, Math.min(lines.length, index + 1 + contextLines)) : undefined;
|
||||
matches.push({
|
||||
file: stats.isDirectory() ? candidate : root.originalPath,
|
||||
resolvedPath,
|
||||
line: index + 1,
|
||||
content: lines[index].trim(),
|
||||
before,
|
||||
after,
|
||||
});
|
||||
if (matches.length >= maxResults) break;
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable and binary files.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches,
|
||||
count: matches.length,
|
||||
tool: 'internal-grep',
|
||||
searchPath: searchPath || '.',
|
||||
resolvedSearchPath: root.resolvedPath,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
tools:
|
||||
file-writeText:
|
||||
workspace-writeFile:
|
||||
type: builtin
|
||||
name: file-writeText
|
||||
file-readText:
|
||||
name: workspace-writeFile
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
name: file-readText
|
||||
file-editText:
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: file-editText
|
||||
file-list:
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: file-list
|
||||
file-mkdir:
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
type: builtin
|
||||
name: file-mkdir
|
||||
name: workspace-mkdir
|
||||
---
|
||||
# Agent Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -264,11 +264,10 @@ async function createNotesFromBatch(
|
|||
message += `**Instructions:**\n`;
|
||||
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
|
||||
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
|
||||
message += `- The source files below are INDEPENDENT — they are batched only for efficiency. Two entities are related ONLY if they co-occur within the same single source file (or in an existing note). NEVER link entities just because they appear in this batch (see "Source Scoping" in your instructions)\n`;
|
||||
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
|
||||
message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`;
|
||||
message += `- If the SAME entity appears in multiple files, merge the information into a single note (this is identity, not a relationship — do not link different entities across files)\n`;
|
||||
message += `- Use file tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
|
||||
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
|
||||
message += `- Use workspace tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
|
||||
message += `- Follow the note templates and guidelines in your instructions\n\n`;
|
||||
|
||||
// Add the knowledge base index
|
||||
|
|
@ -298,16 +297,16 @@ async function createNotesFromBatch(
|
|||
if (event.type !== "tool-invocation") {
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== "file-writeText" && event.toolName !== "file-editText") {
|
||||
if (event.toolName !== "workspace-writeFile" && event.toolName !== "workspace-edit") {
|
||||
return;
|
||||
}
|
||||
const toolPath = extractPathFromToolInput(event.input);
|
||||
if (!toolPath) {
|
||||
return;
|
||||
}
|
||||
if (event.toolName === "file-writeText") {
|
||||
if (event.toolName === "workspace-writeFile") {
|
||||
notesCreated.add(toolPath);
|
||||
} else if (event.toolName === "file-editText") {
|
||||
} else if (event.toolName === "workspace-edit") {
|
||||
notesModified.add(toolPath);
|
||||
}
|
||||
});
|
||||
|
|
@ -358,7 +357,7 @@ async function buildGraphWithFiles(
|
|||
return { processedFiles: [], notesCreated: new Set(), notesModified: new Set(), hadError: false };
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 1; // One source file per agent run — prevents cross-file entity contamination in the graph
|
||||
const BATCH_SIZE = 10; // Reduced from 25 to 10 files per agent run for faster processing
|
||||
const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE);
|
||||
|
||||
console.log(`Processing ${contentFiles.length} files in ${totalBatches} batches (${BATCH_SIZE} files per batch)...`);
|
||||
|
|
@ -544,7 +543,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
|||
}
|
||||
|
||||
// Process in batches like other sources
|
||||
const BATCH_SIZE = 1; // One source file per agent run — prevents cross-file entity contamination in the graph
|
||||
const BATCH_SIZE = 10;
|
||||
const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE);
|
||||
|
||||
const notesCreated = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export interface Classification {
|
|||
const ClassificationSchema = z.object({
|
||||
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
|
||||
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
|
||||
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
|
||||
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
|
||||
|
|
@ -128,18 +128,7 @@ When the thread is important, write a 1-2 sentence summary that captures the gis
|
|||
|
||||
When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is.
|
||||
|
||||
Format it like a real email, not one run-on block. Use actual line breaks: put the greeting on its own line, separate distinct paragraphs with a blank line, and put the sign-off and the name on their own lines. The example below illustrates only the line-break structure — not the wording, tone, greeting, or sign-off to use:
|
||||
|
||||
Hi Tyrone,
|
||||
|
||||
Thanks for the follow-up — sorry I missed your earlier note.
|
||||
|
||||
Could you resend it with a bit more context so I can get back to you properly?
|
||||
|
||||
Best,
|
||||
John
|
||||
|
||||
When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice.
|
||||
Apply the user's email-style guide (when provided below) — match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice.
|
||||
|
||||
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:
|
||||
- Propose 2-3 specific time windows from genuinely free slots, or
|
||||
|
|
@ -155,12 +144,10 @@ Omit the draft when:
|
|||
|
||||
Be decisive — pick exactly one importance label. Do not hedge.`;
|
||||
|
||||
function userSentLatest(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
|
||||
function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
|
||||
if (!userEmail) return false;
|
||||
const latest = snapshot.messages[snapshot.messages.length - 1];
|
||||
if (!latest) return false;
|
||||
const needle = userEmail.toLowerCase();
|
||||
return (latest.from || '').toLowerCase().includes(needle);
|
||||
return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
|
|
@ -219,7 +206,7 @@ export async function classifyThread(
|
|||
userEmail: string | null,
|
||||
options: ClassifyOptions = {},
|
||||
): Promise<Classification> {
|
||||
if (userSentLatest(snapshot, userEmail)) {
|
||||
if (userReplied(snapshot, userEmail)) {
|
||||
return { importance: 'important' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { OAuthTokens } from '../auth/types.js';
|
||||
|
||||
/**
|
||||
* Regression for the cold-start race that left a stuck `error` field in
|
||||
* oauth.json: Gmail + Calendar both call getClient() in the same tick, the
|
||||
* dedup singleton's check-and-assign were separated by an `await`, two
|
||||
* parallel refreshes go out, backend 429s the second one, the upsert(error)
|
||||
* write from the 429 path could land last and stick "Needs reconnect" in
|
||||
* the UI even though tokens were valid.
|
||||
*/
|
||||
|
||||
interface MockOAuthRepo {
|
||||
read: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
getClientFacingConfig: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let refreshSpy: ReturnType<typeof vi.fn>;
|
||||
let mockOAuthRepo: MockOAuthRepo;
|
||||
let storedTokens: OAuthTokens;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
||||
// Expired 1 minute ago — forces the refresh path through getClient.
|
||||
storedTokens = {
|
||||
access_token: 'old-access',
|
||||
refresh_token: 'rt',
|
||||
expires_at: Math.floor(Date.now() / 1000) - 60,
|
||||
token_type: 'Bearer',
|
||||
scopes: ['https://www.googleapis.com/auth/gmail.modify'],
|
||||
};
|
||||
|
||||
mockOAuthRepo = {
|
||||
read: vi.fn(async () => ({ tokens: storedTokens, mode: 'rowboat' as const })),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
delete: vi.fn(async () => undefined),
|
||||
getClientFacingConfig: vi.fn(async () => ({})),
|
||||
};
|
||||
|
||||
vi.doMock('../di/container.js', () => ({
|
||||
default: {
|
||||
resolve: (key: string) => {
|
||||
if (key === 'oauthRepo') return mockOAuthRepo;
|
||||
throw new Error(`unexpected DI resolve in test: ${key}`);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Real-ish delay so two concurrent callers actually have something to
|
||||
// overlap on — without it the spy might resolve synchronously and mask
|
||||
// the very race we're testing for.
|
||||
refreshSpy = vi.fn(async (_rt: string, scopes?: string[]) => {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
return {
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'rt',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
token_type: 'Bearer' as const,
|
||||
scopes,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock('../auth/google-backend-oauth.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../auth/google-backend-oauth.js')>(
|
||||
'../auth/google-backend-oauth.js',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
refreshTokensViaBackend: refreshSpy,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../di/container.js');
|
||||
vi.doUnmock('../auth/google-backend-oauth.js');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('GoogleClientFactory.getClient', () => {
|
||||
it('coalesces concurrent callers into a single refresh', async () => {
|
||||
const { GoogleClientFactory } = await import('./google-client-factory.js');
|
||||
GoogleClientFactory.clearCache();
|
||||
|
||||
// Same tick — this is the exact pattern that sync_gmail.init() and
|
||||
// sync_calendar.init() produce on cold start.
|
||||
const [a, b] = await Promise.all([
|
||||
GoogleClientFactory.getClient(),
|
||||
GoogleClientFactory.getClient(),
|
||||
]);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a).toBe(b);
|
||||
|
||||
// And the failure-path upsert (error: '429…') is never invoked, so
|
||||
// oauth.json doesn't get a stuck error.
|
||||
const errorUpserts = mockOAuthRepo.upsert.mock.calls.filter(
|
||||
([, conn]) => (conn as { error?: string | null }).error,
|
||||
);
|
||||
expect(errorUpserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns cached client when tokens are not expired', async () => {
|
||||
// Tokens valid for another hour — no refresh should fire.
|
||||
storedTokens = {
|
||||
access_token: 'fresh-access',
|
||||
refresh_token: 'rt',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
token_type: 'Bearer',
|
||||
scopes: ['https://www.googleapis.com/auth/gmail.modify'],
|
||||
};
|
||||
mockOAuthRepo.read = vi.fn(async () => ({ tokens: storedTokens, mode: 'rowboat' as const }));
|
||||
|
||||
const { GoogleClientFactory } = await import('./google-client-factory.js');
|
||||
GoogleClientFactory.clearCache();
|
||||
|
||||
const a = await GoogleClientFactory.getClient();
|
||||
const b = await GoogleClientFactory.getClient();
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('does not stick an error on transient (429) refresh failure', async () => {
|
||||
const { TransientRefreshError } = await import('../auth/google-backend-oauth.js');
|
||||
refreshSpy.mockRejectedValueOnce(new TransientRefreshError('refresh failed: 429 Refresh in progress', 429));
|
||||
|
||||
const { GoogleClientFactory } = await import('./google-client-factory.js');
|
||||
GoogleClientFactory.clearCache();
|
||||
|
||||
const result = await GoogleClientFactory.getClient();
|
||||
|
||||
expect(result).toBeNull();
|
||||
const errorUpserts = mockOAuthRepo.upsert.mock.calls.filter(
|
||||
([, conn]) => (conn as { error?: string | null }).error,
|
||||
);
|
||||
expect(errorUpserts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@ import type { Configuration } from '../auth/oauth-client.js';
|
|||
import { OAuthTokens } from '../auth/types.js';
|
||||
import {
|
||||
ReconnectRequiredError,
|
||||
TransientRefreshError,
|
||||
refreshTokensViaBackend,
|
||||
} from '../auth/google-backend-oauth.js';
|
||||
|
||||
|
|
@ -53,14 +52,11 @@ export class GoogleClientFactory {
|
|||
};
|
||||
|
||||
/**
|
||||
* Promise singleton so concurrent getClient() callers share a single
|
||||
* pass through the read/refresh/build pipeline rather than fanning
|
||||
* out parallel refreshes. The check-and-assign must be atomic (no
|
||||
* `await` between them) so two callers in the same tick can't both
|
||||
* pass the null check before either assigns — that's why getClient()
|
||||
* is a thin synchronous wrapper around getClientInner().
|
||||
* Promise singleton so a burst of getClient() calls during the brief
|
||||
* expiry window all wait on a single refresh round-trip rather than
|
||||
* fanning out parallel refreshes.
|
||||
*/
|
||||
private static inFlightClient: Promise<OAuth2Client | null> | null = null;
|
||||
private static refreshInFlight: Promise<OAuth2Client | null> | null = null;
|
||||
|
||||
private static async resolveByokCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
|
|
@ -73,24 +69,13 @@ export class GoogleClientFactory {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get or create OAuth2Client, reusing the cached instance when possible.
|
||||
*
|
||||
* The check-and-assign of `inFlightClient` is synchronous so concurrent
|
||||
* callers in the same tick coalesce onto a single pipeline run. The actual
|
||||
* work lives in getClientInner(); this wrapper exists purely to guarantee
|
||||
* the dedup invariant.
|
||||
* Get or create OAuth2Client, reusing cached instance when possible
|
||||
*/
|
||||
static async getClient(): Promise<OAuth2Client | null> {
|
||||
if (this.inFlightClient) {
|
||||
return this.inFlightClient;
|
||||
if (this.refreshInFlight) {
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
this.inFlightClient = this.getClientInner().finally(() => {
|
||||
this.inFlightClient = null;
|
||||
});
|
||||
return this.inFlightClient;
|
||||
}
|
||||
|
||||
private static async getClientInner(): Promise<OAuth2Client | null> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
const tokens = connection.tokens ?? null;
|
||||
|
|
@ -125,12 +110,16 @@ export class GoogleClientFactory {
|
|||
// expiry — keeps long-running calls from racing the boundary.
|
||||
if (oauthClient.isTokenExpired(tokens)) {
|
||||
if (!tokens.refresh_token) {
|
||||
console.log('[OAuth] Google token expired and no refresh token available.');
|
||||
console.log('[OAuth] Token expired and no refresh token available for Google.');
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
return this.refreshAndBuild(tokens, mode);
|
||||
|
||||
this.refreshInFlight = this.refreshAndBuild(tokens, mode).finally(() => {
|
||||
this.refreshInFlight = null;
|
||||
});
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
|
||||
// Reuse client if tokens haven't changed
|
||||
|
|
@ -146,8 +135,7 @@ export class GoogleClientFactory {
|
|||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
|
||||
try {
|
||||
const secsSinceExpiry = Math.floor(Date.now() / 1000) - tokens.expires_at;
|
||||
console.log(`[OAuth] Google token expired ${secsSinceExpiry}s ago, refreshing via ${mode}...`);
|
||||
console.log(`[OAuth] Token expired, refreshing via ${mode}...`);
|
||||
const existingScopes = tokens.scopes;
|
||||
|
||||
let refreshedTokens: OAuthTokens;
|
||||
|
|
@ -162,8 +150,7 @@ export class GoogleClientFactory {
|
|||
}
|
||||
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens, error: null });
|
||||
const ttl = refreshedTokens.expires_at - Math.floor(Date.now() / 1000);
|
||||
console.log(`[OAuth] Google token refreshed successfully (mode=${mode}, new expires_at=${refreshedTokens.expires_at}, ttl=${ttl}s)`);
|
||||
console.log('[OAuth] Token refreshed successfully');
|
||||
return this.buildAndCacheClient(refreshedTokens, mode);
|
||||
} catch (error) {
|
||||
if (error instanceof ReconnectRequiredError) {
|
||||
|
|
@ -172,24 +159,9 @@ export class GoogleClientFactory {
|
|||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
if (error instanceof TransientRefreshError) {
|
||||
// Transient (rate limit, in-flight dedup, upstream 5xx): leave
|
||||
// stored tokens + cache alone, log, and let the next sync tick
|
||||
// retry. Writing an `error` here would stick "Needs reconnect"
|
||||
// in the UI for a problem the user can't fix by reconnecting.
|
||||
console.warn(`[OAuth] Transient Google refresh failure (status=${error.status}): ${error.message} — will retry on next tick`);
|
||||
return null;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
|
||||
console.error('[OAuth] Failed to refresh token for Google:', error);
|
||||
// Walk cause chain so we can see e.g. `Not signed into Rowboat`
|
||||
// showing up under a generic `fetch failed` outer error.
|
||||
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);
|
||||
}
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
|
|
@ -216,41 +188,18 @@ export class GoogleClientFactory {
|
|||
* Check if credentials are available and have required scopes
|
||||
*/
|
||||
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
||||
const status = await this.getCredentialStatus(requiredScopes);
|
||||
return status.hasRequiredScopes;
|
||||
}
|
||||
|
||||
static async getCredentialStatus(requiredScopes: string | string[]): Promise<{
|
||||
connected: boolean;
|
||||
hasRequiredScopes: boolean;
|
||||
missingScopes: string[];
|
||||
}> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
if (!tokens) {
|
||||
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
|
||||
return {
|
||||
connected: false,
|
||||
hasRequiredScopes: false,
|
||||
missingScopes: scopesArray,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if required scope(s) are present
|
||||
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
|
||||
const granted = new Set(tokens.scopes ?? []);
|
||||
const missingScopes = scopesArray.filter(scope => !granted.has(scope));
|
||||
if (!tokens.scopes || tokens.scopes.length === 0) {
|
||||
return {
|
||||
connected: true,
|
||||
hasRequiredScopes: false,
|
||||
missingScopes,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
hasRequiredScopes: missingScopes.length === 0,
|
||||
missingScopes,
|
||||
};
|
||||
return scopesArray.every(scope => tokens.scopes!.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
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